From 00985f79ea7f1c8c5a2eb0e7328532a082d6aba3 Mon Sep 17 00:00:00 2001 From: "Joshua D. Drake" Date: Mon, 9 Nov 2026 18:46:19 +0000 Subject: [PATCH 1/4] Fix backend crashes and output blowups found by a security audit Every plx language is trusted, so any role with USAGE can send arbitrary input to the transpiler through CREATE FUNCTION or DO. - plx_span_text: skip type-annotation tokens (which carry no source span) at the edges of a span. A span beginning on one computed its length from a NULL pointer and crashed the backend (plxjs/ts/php/ruby/python3). - plxtsql, plxgo, plxcobol: guard the expression emitters with check_stack_depth() and the PLX_MAX_DEPTH counter. Deeply nested calls, conversions or subscripts overflowed the C stack (SIGSEGV). - finally/ensure: cap finally-inside-finally nesting at 8 levels; each level doubled the generated body, so a few hundred bytes of source could demand gigabytes. The three copies of the try lowering are now one helper, plx_emit_try, which also runs the normal-path finally body after the protected block, so a finally that raises no longer runs twice. - plxgo fmt.Sprintf: require a letter verb before lowering to a || chain; a '%' followed by a quote consumed half of an escaped '' pair and let Go string text escape into the generated SQL. Regression tests cover each case. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 37 +++++++++++++++++++ src/plx_dialect_cobol.c | 19 +++++++++- src/plx_dialect_go.c | 58 ++++++++++++++++++++++++++++- src/plx_dialect_python.c | 41 ++------------------- src/plx_dialect_ruby.c | 41 ++------------------- src/plx_dialect_tsql.c | 17 ++++++++- src/plx_engine.h | 6 +++ src/plx_parse_brace.c | 41 ++------------------- src/plx_transpile.c | 71 ++++++++++++++++++++++++++++++++++++ test/expected/plxcobol.out | 13 +++++++ test/expected/plxgo.out | 16 ++++++++ test/expected/plxjs.out | 28 ++++++++++++++ test/expected/plxpython3.out | 6 +++ test/expected/plxtsql.out | 6 +++ test/sql/plxcobol.sql | 13 +++++++ test/sql/plxgo.sql | 12 ++++++ test/sql/plxjs.sql | 21 +++++++++++ test/sql/plxpython3.sql | 7 ++++ test/sql/plxtsql.sql | 6 +++ 19 files changed, 342 insertions(+), 117 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1930387..389780f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,43 @@ All notable changes to plx are recorded here. The format follows [Keep a Changelog](https://keepachangelog.com/), and plx uses the extension version in `plx.control` (currently `1.0`). +## [Unreleased] + +Security fixes from a code audit. Code-only: no SQL objects change, so a new +build and a server restart (or new sessions) pick them up without +`ALTER EXTENSION`. Every fix concerns input to `CREATE FUNCTION` or `DO`, which +any role with USAGE on a plx language can send (the languages are trusted, so +PUBLIC has USAGE by default). + +### Security + +- **Backend crash on a type annotation at the start of an expression.** A + `/*:: T */` or `#:: T` annotation as the first token of an `if`/`while` + condition, a call argument, or a statement made the transpiler copy a span + from a NULL pointer, killing the backend with SIGSEGV (and resetting every + session on the server). Affected plxjs, plxts, plxphp, plxruby, plxpython3, + including DO blocks. Annotations are now skipped at span edges. +- **Stack-overflow crash on deeply nested expressions in plxtsql, plxgo and + plxcobol.** Their expression emitters recursed once per nested call, + subscript or literal without `check_stack_depth()`, so a body like + `ISNULL(ISNULL(...))` or `int(int(...))` some 50,000 levels deep crashed the + backend. They now raise `expression nested too deeply`, like the shared + rewriter already did. +- **Exponential output from nested `finally`/`ensure`.** Each finally body is + emitted once per exit path, so a finally nested inside a finally doubled the + output per level: a 668-byte function stored a 94 MB body, and a slightly + deeper one drove a backend to gigabytes of memory. Nesting a finally inside a + finally is now limited to 8 levels. +- **plxgo `fmt.Sprintf` directive without a letter verb.** A `%` followed by a + quote consumed half of the escaped `''` pair, so text inside a Go string + literal could end up as SQL in the generated body. Such directives now fall + back to the `format()` lowering, which keeps the text literal. + +### Fixed + +- A `finally`/`ensure` body that raised an error on the normal path ran a + second time from the error path. It now runs once. + ## [2.0.1] - 2026-08-25 Packaging only. **The extension is unchanged at 2.0.0**, so there is no diff --git a/src/plx_dialect_cobol.c b/src/plx_dialect_cobol.c index 33ffa49..0cf6554 100644 --- a/src/plx_dialect_cobol.c +++ b/src/plx_dialect_cobol.c @@ -442,8 +442,10 @@ cob_is_array(Cb *cb, const char *name) * noise word, figurative constants, ** -> ^, data-name mapping, and array * subscripts (WS-ARR(I) -> ws_arr[i]). */ +static void cob_emit_range(Cb *cb, int a, int b, StringInfo out); + static void -cob_emit_range(Cb *cb, int a, int b, StringInfo out) +cob_emit_range_inner(Cb *cb, int a, int b, StringInfo out) { CbTok *t = cb->t; int i = a; @@ -621,6 +623,21 @@ cob_emit_range(Cb *cb, int a, int b, StringInfo out) } } +/* + * Recursion guard for the expression emitter. Nested calls, subscripts and + * literals recurse through here once per level; without check_stack_depth() + * hostile nesting overflows the C stack and crashes the backend (SIGSEGV). + */ +static void +cob_emit_range(Cb *cb, int a, int b, StringInfo out) +{ + check_stack_depth(); + if (++cb->cx->depth > PLX_MAX_DEPTH) + cob_err(cb, "expression nested too deeply"); + cob_emit_range_inner(cb, a, b, out); + cb->cx->depth--; +} + /* true if the current token stops a value scan */ static bool cob_value_stop(Cb *cb, const char *const *stops, int nstops) diff --git a/src/plx_dialect_go.c b/src/plx_dialect_go.c index be53a05..08383fc 100644 --- a/src/plx_dialect_go.c +++ b/src/plx_dialect_go.c @@ -489,6 +489,36 @@ go_emit_format_str(GoTok *tk, StringInfo out) pfree(raw.data); } +/* + * Index of the verb of the fmt directive whose '%' is at raw->data[pct], after + * skipping flags, width and precision; -1 when that character is not an ASCII + * letter (or the directive runs into the closing quote). + */ +static int +go_sprintf_verb(StringInfo raw, int pct) +{ + int j = pct + 1; + + while (j + 1 < raw->len && (raw->data[j] == '-' || raw->data[j] == '+' || + raw->data[j] == ' ' || raw->data[j] == '#' || + raw->data[j] == '0')) + j++; + while (j + 1 < raw->len && raw->data[j] >= '0' && raw->data[j] <= '9') + j++; + if (j + 1 < raw->len && raw->data[j] == '.') + { + j++; + while (j + 1 < raw->len && raw->data[j] >= '0' && raw->data[j] <= '9') + j++; + } + if (j + 1 >= raw->len) + return -1; + if (!((raw->data[j] >= 'a' && raw->data[j] <= 'z') || + (raw->data[j] >= 'A' && raw->data[j] <= 'Z'))) + return -1; + return j; +} + /* fmt.Sprintf with a literal format string, lowered to a || chain. * format() renders a NULL operand as the empty string and cannot be made to do @@ -565,6 +595,17 @@ go_emit_sprintf_concat(Go *g, int lp, int close, StringInfo out) i++; continue; } + /* + * The verb must be a letter. raw is already a SQL literal (' + * doubled), so a quote here would otherwise be consumed as the verb + * and leave half of a '' pair unescaped in the output; fall back to + * the format() path, which validates verbs, instead. + */ + if (go_sprintf_verb(&raw, i) < 0) + { + pfree(raw.data); + return false; + } ndir++; } if (ndir != nargs) @@ -961,7 +1002,7 @@ go_emit_call(Go *g, int i, int b, StringInfo out, int *ni) /* emit tokens [a,b) as a plpgsql expression */ static void -go_emit_range(Go *g, int a, int b, StringInfo out) +go_emit_range_inner(Go *g, int a, int b, StringInfo out) { int i = a; @@ -1117,6 +1158,21 @@ go_emit_range(Go *g, int a, int b, StringInfo out) } } +/* + * Recursion guard for the expression emitter. Nested calls, subscripts and + * literals recurse through here once per level; without check_stack_depth() + * hostile nesting overflows the C stack and crashes the backend (SIGSEGV). + */ +static void +go_emit_range(Go *g, int a, int b, StringInfo out) +{ + check_stack_depth(); + if (++g->cx->depth > PLX_MAX_DEPTH) + go_err(g, "expression nested too deeply"); + go_emit_range_inner(g, a, b, out); + g->cx->depth--; +} + /* end of a simple statement: the next top-level ; (brackets/braces nest) */ static int go_stmt_end(Go *g, int from) diff --git a/src/plx_dialect_python.c b/src/plx_dialect_python.c index 0daba37..077236b 100644 --- a/src/plx_dialect_python.c +++ b/src/plx_dialect_python.c @@ -354,52 +354,17 @@ parse_py_try(Ctx *cx, int ind) colon = py_header_colon(cx, cx->pos + 1); if (colon < 0) plx_err(cx, cx->t[cx->pos].line, "finally requires ':'"); + plx_ensure_enter(cx, cx->t[cx->pos].line); cx->pos = colon + 1; saved = cx->out; initStringInfo(&cx->out); parse_py_block(cx, ind + 1); + cx->ensuredepth--; ensure_body = cx->out.data; cx->out = saved; } - if (!ensure_body) - { - plx_indent(o, ind); - appendStringInfoString(o, "BEGIN\n"); - appendStringInfoString(o, body); - if (nrescue) - { - plx_indent(o, ind); - appendStringInfoString(o, "EXCEPTION\n"); - appendStringInfoString(o, arms.data); - } - plx_indent(o, ind); - appendStringInfoString(o, "END;\n"); - } - else - { - plx_indent(o, ind); - appendStringInfoString(o, "BEGIN\n"); - plx_indent(o, ind + 1); - appendStringInfoString(o, "BEGIN\n"); - appendStringInfoString(o, body); - if (nrescue) - { - plx_indent(o, ind + 1); - appendStringInfoString(o, "EXCEPTION\n"); - appendStringInfoString(o, arms.data); - } - plx_indent(o, ind + 1); - appendStringInfoString(o, "END;\n"); - appendStringInfoString(o, ensure_body); - plx_indent(o, ind); - appendStringInfoString(o, "EXCEPTION WHEN OTHERS THEN\n"); - appendStringInfoString(o, ensure_body); - plx_indent(o, ind + 1); - appendStringInfoString(o, "RAISE;\n"); - plx_indent(o, ind); - appendStringInfoString(o, "END;\n"); - } + plx_emit_try(o, ind, body, nrescue, arms.data, ensure_body); } /* raise / raise Class("msg") */ diff --git a/src/plx_dialect_ruby.c b/src/plx_dialect_ruby.c index 918a950..58d7e5e 100644 --- a/src/plx_dialect_ruby.c +++ b/src/plx_dialect_ruby.c @@ -484,8 +484,10 @@ parse_begin(Ctx *cx, int ind) if (cx->t[cx->pos].kind == T_KW && cx->t[cx->pos].kw == KW_ENSURE) { + plx_ensure_enter(cx, cx->t[cx->pos].line); cx->pos++; ensure_body = capture_block(cx, ind + 1); + cx->ensuredepth--; } if (cx->t[cx->pos].kind == T_KW && cx->t[cx->pos].kw == KW_END) @@ -493,44 +495,7 @@ parse_begin(Ctx *cx, int ind) else plx_err(cx, bl, "unterminated begin block"); - if (!ensure_body) - { - plx_indent(o, ind); - appendStringInfoString(o, "BEGIN\n"); - appendStringInfoString(o, body); - if (nrescue) - { - plx_indent(o, ind); - appendStringInfoString(o, "EXCEPTION\n"); - appendStringInfoString(o, arms.data); - } - plx_indent(o, ind); - appendStringInfoString(o, "END;\n"); - } - else - { - plx_indent(o, ind); - appendStringInfoString(o, "BEGIN\n"); - plx_indent(o, ind + 1); - appendStringInfoString(o, "BEGIN\n"); - appendStringInfoString(o, body); - if (nrescue) - { - plx_indent(o, ind + 1); - appendStringInfoString(o, "EXCEPTION\n"); - appendStringInfoString(o, arms.data); - } - plx_indent(o, ind + 1); - appendStringInfoString(o, "END;\n"); - appendStringInfoString(o, ensure_body); - plx_indent(o, ind); - appendStringInfoString(o, "EXCEPTION WHEN OTHERS THEN\n"); - appendStringInfoString(o, ensure_body); - plx_indent(o, ind + 1); - appendStringInfoString(o, "RAISE;\n"); - plx_indent(o, ind); - appendStringInfoString(o, "END;\n"); - } + plx_emit_try(o, ind, body, nrescue, arms.data, ensure_body); } /* rewrite a comma-separated value list (for CASE WHEN v1, v2) */ diff --git a/src/plx_dialect_tsql.c b/src/plx_dialect_tsql.c index fd03afd..c3b02e6 100644 --- a/src/plx_dialect_tsql.c +++ b/src/plx_dialect_tsql.c @@ -679,7 +679,7 @@ tq_emit_call(Tq *tq, int i, int b, StringInfo out, int *ni) /* emit tokens [a,b) as a plpgsql expression */ static void -tq_emit_range(Tq *tq, int a, int b, StringInfo out) +tq_emit_range_inner(Tq *tq, int a, int b, StringInfo out) { int i = a; @@ -782,6 +782,21 @@ tq_emit_range(Tq *tq, int a, int b, StringInfo out) } } +/* + * Recursion guard for the expression emitter. Nested calls, subscripts and + * literals recurse through here once per level; without check_stack_depth() + * hostile nesting overflows the C stack and crashes the backend (SIGSEGV). + */ +static void +tq_emit_range(Tq *tq, int a, int b, StringInfo out) +{ + check_stack_depth(); + if (++tq->cx->depth > PLX_MAX_DEPTH) + tq_err(tq, "expression nested too deeply"); + tq_emit_range_inner(tq, a, b, out); + tq->cx->depth--; +} + /* end of an expression/raw statement: first depth-0 ; EOF END or ELSE */ static int tq_value_end(Tq *tq, int from) diff --git a/src/plx_engine.h b/src/plx_engine.h index 434be02..73c9ac7 100644 --- a/src/plx_engine.h +++ b/src/plx_engine.h @@ -27,6 +27,8 @@ #endif #define PLX_MAX_DEPTH 500 /* recursion cap (parsers + expr rewriter) */ +#define PLX_MAX_ENSURE_DEPTH 8 /* finally-in-finally cap; each level doubles + * the inner body (see plx_emit_try) */ /* stacked-diagnostics field bits (e.detail, e.hint, ...); used by the engine's * diagnostics emitter and by the dialect rescue/try parsers. */ @@ -92,6 +94,7 @@ typedef struct PlxCtx int loopdepth; int depth; /* recursion depth guard */ int handlerdepth; /* inside a rescue handler body */ + int ensuredepth; /* finally/ensure nested inside finally/ensure */ int subq; /* __plx_fo_N / __plx_p_N counter */ const char *exc_var; /* current rescue exception var name */ int exc_varlen; @@ -133,6 +136,9 @@ bool plx_tok_is(Tok *tk, const char *op); bool plx_name_eq(Tok *tk, const char *w); int plx_stmt_end(Ctx *cx, int from); void plx_indent(StringInfo o, int n); +void plx_ensure_enter(Ctx *cx, int line); +void plx_emit_try(StringInfo o, int ind, const char *body, int nrescue, + const char *arms, const char *ensure_body); PlxLocal2 *plx_local_add(Ctx *cx, const char *name, int len); PlxLocal2 *plx_local_find(Ctx *cx, const char *name, int len); bool plx_is_param(Ctx *cx, const char *name, int len); diff --git a/src/plx_parse_brace.c b/src/plx_parse_brace.c index 49b8ee3..a65dd76 100644 --- a/src/plx_parse_brace.c +++ b/src/plx_parse_brace.c @@ -427,51 +427,16 @@ parse_try_brace(Ctx *cx, int ind) { StringInfoData saved = cx->out; + plx_ensure_enter(cx, cx->t[cx->pos].line); cx->pos++; initStringInfo(&cx->out); parse_brace_block(cx, ind + 1); + cx->ensuredepth--; ensure_body = cx->out.data; cx->out = saved; } - if (!ensure_body) - { - plx_indent(o, ind); - appendStringInfoString(o, "BEGIN\n"); - appendStringInfoString(o, body); - if (nrescue) - { - plx_indent(o, ind); - appendStringInfoString(o, "EXCEPTION\n"); - appendStringInfoString(o, arms.data); - } - plx_indent(o, ind); - appendStringInfoString(o, "END;\n"); - } - else - { - plx_indent(o, ind); - appendStringInfoString(o, "BEGIN\n"); - plx_indent(o, ind + 1); - appendStringInfoString(o, "BEGIN\n"); - appendStringInfoString(o, body); - if (nrescue) - { - plx_indent(o, ind + 1); - appendStringInfoString(o, "EXCEPTION\n"); - appendStringInfoString(o, arms.data); - } - plx_indent(o, ind + 1); - appendStringInfoString(o, "END;\n"); - appendStringInfoString(o, ensure_body); - plx_indent(o, ind); - appendStringInfoString(o, "EXCEPTION WHEN OTHERS THEN\n"); - appendStringInfoString(o, ensure_body); - plx_indent(o, ind + 1); - appendStringInfoString(o, "RAISE;\n"); - plx_indent(o, ind); - appendStringInfoString(o, "END;\n"); - } + plx_emit_try(o, ind, body, nrescue, arms.data, ensure_body); (void) line; } diff --git a/src/plx_transpile.c b/src/plx_transpile.c index 0a23018..80e2d47 100644 --- a/src/plx_transpile.c +++ b/src/plx_transpile.c @@ -1471,6 +1471,15 @@ plx_span_text(Ctx *cx, int a, int b) { const char *st, *en; + /* + * Type-annotation tokens carry no source span (s == NULL), so trim them + * off both ends; a span that began on one used to compute its length + * from a NULL pointer and crash the backend in pnstrdup. + */ + while (a < b && cx->t[a].s == NULL) + a++; + while (b > a && cx->t[b - 1].s == NULL) + b--; if (b <= a) return pstrdup(""); st = cx->t[a].s; @@ -1512,6 +1521,68 @@ plx_indent(StringInfo o, int n) appendStringInfoString(o, " "); } +/* + * Enter a finally/ensure body. The lowering below emits that body twice (once + * per exit path), so a finally nested inside a finally doubles per level: a + * few hundred bytes of source could otherwise demand gigabytes of output. The + * caller decrements cx->ensuredepth after parsing the body. + */ +void +plx_ensure_enter(Ctx *cx, int line) +{ + if (++cx->ensuredepth > PLX_MAX_ENSURE_DEPTH) + plx_err(cx, line, "finally/ensure blocks nested too deeply inside one another"); +} + +/* + * Emit a try/rescue[/finally] statement (shared by every front end): + * + * BEGIN -- only when there is a finally body + * BEGIN body EXCEPTION arms END; + * EXCEPTION WHEN OTHERS THEN + * finally; RAISE; + * END; + * finally; -- normal path, outside the handler + * + * The normal-path copy runs after the protected block rather than inside it, + * so an error raised by the finally body itself is not caught and does not + * run the finally body a second time. + */ +void +plx_emit_try(StringInfo o, int ind, const char *body, int nrescue, + const char *arms, const char *ensure_body) +{ + int in = ensure_body ? ind + 1 : ind; + + if (ensure_body) + { + plx_indent(o, ind); + appendStringInfoString(o, "BEGIN\n"); + } + plx_indent(o, in); + appendStringInfoString(o, "BEGIN\n"); + appendStringInfoString(o, body); + if (nrescue) + { + plx_indent(o, in); + appendStringInfoString(o, "EXCEPTION\n"); + appendStringInfoString(o, arms); + } + plx_indent(o, in); + appendStringInfoString(o, "END;\n"); + if (ensure_body) + { + plx_indent(o, ind); + appendStringInfoString(o, "EXCEPTION WHEN OTHERS THEN\n"); + appendStringInfoString(o, ensure_body); + plx_indent(o, ind + 1); + appendStringInfoString(o, "RAISE;\n"); + plx_indent(o, ind); + appendStringInfoString(o, "END;\n"); + appendStringInfoString(o, ensure_body); + } +} + /* ---------------------------------------------------------------- leaves */ /* find end of current logical statement: index of NEWLINE/SEMI/EOF */ diff --git a/test/expected/plxcobol.out b/test/expected/plxcobol.out index cf488c0..5b7a6cb 100644 --- a/test/expected/plxcobol.out +++ b/test/expected/plxcobol.out @@ -490,3 +490,16 @@ SELECT msg FROM cob_log; logged (1 row) +-- hostile subscript nesting raises a clean error instead of overflowing the stack +DO $d$ BEGIN + EXECUTE 'CREATE FUNCTION cob_deep() RETURNS int LANGUAGE plxcobol AS $b$ +DATA DIVISION. +WORKING-STORAGE SECTION. +01 WS-ARR PIC 9(9) OCCURS 5 TIMES. +01 WS-X PIC 9(9). +PROCEDURE DIVISION. + COMPUTE WS-X = ' || repeat('WS-ARR(', 2000) || '1' || repeat(')', 2000) || ' + GOBACK. +$b$'; +EXCEPTION WHEN OTHERS THEN RAISE WARNING 'caught: %', SQLERRM; END $d$; +WARNING: caught: plxcobol: expression nested too deeply diff --git a/test/expected/plxgo.out b/test/expected/plxgo.out index 549dea0..b15be36 100644 --- a/test/expected/plxgo.out +++ b/test/expected/plxgo.out @@ -352,3 +352,19 @@ CREATE FUNCTION g_panic_msg(who text) RETURNS int LANGUAGE plxgo AS $$ $$; DO $d$ BEGIN PERFORM g_panic_msg(NULL); EXCEPTION WHEN OTHERS THEN RAISE NOTICE 'caught: [%]', SQLERRM; END $d$; +-- hostile nesting raises a clean error instead of overflowing the C stack +DO $d$ BEGIN + EXECUTE 'CREATE FUNCTION g_deep() RETURNS int LANGUAGE plxgo AS $b$return ' + || repeat('int(', 2000) || '1' || repeat(')', 2000) || E'\n$b$'; +EXCEPTION WHEN OTHERS THEN RAISE WARNING 'caught: %', SQLERRM; END $d$; +WARNING: caught: plxgo: expression nested too deeply +-- a Sprintf directive without a letter verb stays inside the literal +CREATE FUNCTION g_badverb(x text) RETURNS text LANGUAGE plxgo AS $$ + return fmt.Sprintf("%' || version() || '", x) +$$; +SELECT g_badverb('v'); + g_badverb +---------------------- + %' || version() || ' +(1 row) + diff --git a/test/expected/plxjs.out b/test/expected/plxjs.out index baf4fdb..42bb65a 100644 --- a/test/expected/plxjs.out +++ b/test/expected/plxjs.out @@ -241,3 +241,31 @@ SELECT js_flt() AS flt, js_exp() AS exp; 3.14 | 1500 (1 row) +-- a type annotation at the start of a condition is ignored (it used to give a +-- span with no source text and crash the backend) +CREATE FUNCTION js_ann_cond(x int) RETURNS int LANGUAGE plxjs AS $$ + if (/*:: int */ x > 0) { return 1; } + return 0; +$$; +SELECT js_ann_cond(3), js_ann_cond(-3); + js_ann_cond | js_ann_cond +-------------+------------- + 1 | 0 +(1 row) + +-- finally inside finally is capped (each level doubles the generated body) +DO $d$ DECLARE s text := 'x = 1;'; BEGIN + FOR i IN 1..12 LOOP s := 'try { x = x + 1; } finally { ' || s || ' }'; END LOOP; + EXECUTE 'CREATE FUNCTION js_fin_deep() RETURNS int LANGUAGE plxjs AS $b$ let x /*:: int */ = 0; ' + || s || ' return x; $b$'; +EXCEPTION WHEN OTHERS THEN RAISE WARNING 'caught: %', SQLERRM; END $d$; +WARNING: caught: plxjs: finally/ensure blocks nested too deeply inside one another +-- a finally body that throws runs once, not again from the error path +DO LANGUAGE plxjs $p$ + try { raise("warning", "body"); } + finally { raise("warning", "finally ran"); throw new Exception("boom"); } +$p$; +WARNING: body +WARNING: finally ran +ERROR: boom +CONTEXT: PL/pgSQL function inline_code_block line 13 at RAISE diff --git a/test/expected/plxpython3.out b/test/expected/plxpython3.out index 1e0359f..957a353 100644 --- a/test/expected/plxpython3.out +++ b/test/expected/plxpython3.out @@ -201,3 +201,9 @@ SELECT py_isnull(NULL), py_isnull(5); null | notnull (1 row) +-- a type annotation inside a call argument list is ignored (it used to give a +-- span with no source text and crash the backend) +DO LANGUAGE plxpython3 $p$ +assert(#:: int + 1 > 0) +$p$; diff --git a/test/expected/plxtsql.out b/test/expected/plxtsql.out index daa328c..628c11d 100644 --- a/test/expected/plxtsql.out +++ b/test/expected/plxtsql.out @@ -205,3 +205,9 @@ SELECT id, total, tag FROM tq_trg; (1 row) DROP TABLE tq_trg CASCADE; +-- hostile nesting raises a clean error instead of overflowing the C stack +DO $d$ BEGIN + EXECUTE 'DO LANGUAGE plxtsql $b$RETURN ' + || repeat('ISNULL(', 2000) || '1' || repeat(', 0)', 2000) || ';$b$'; +EXCEPTION WHEN OTHERS THEN RAISE WARNING 'caught: %', SQLERRM; END $d$; +WARNING: caught: plxtsql: expression nested too deeply diff --git a/test/sql/plxcobol.sql b/test/sql/plxcobol.sql index 0bc2f3e..44474e4 100644 --- a/test/sql/plxcobol.sql +++ b/test/sql/plxcobol.sql @@ -376,3 +376,16 @@ PROCEDURE DIVISION. $$; SELECT cob_donote(); SELECT msg FROM cob_log; + +-- hostile subscript nesting raises a clean error instead of overflowing the stack +DO $d$ BEGIN + EXECUTE 'CREATE FUNCTION cob_deep() RETURNS int LANGUAGE plxcobol AS $b$ +DATA DIVISION. +WORKING-STORAGE SECTION. +01 WS-ARR PIC 9(9) OCCURS 5 TIMES. +01 WS-X PIC 9(9). +PROCEDURE DIVISION. + COMPUTE WS-X = ' || repeat('WS-ARR(', 2000) || '1' || repeat(')', 2000) || ' + GOBACK. +$b$'; +EXCEPTION WHEN OTHERS THEN RAISE WARNING 'caught: %', SQLERRM; END $d$; diff --git a/test/sql/plxgo.sql b/test/sql/plxgo.sql index c3565c3..c11e9c2 100644 --- a/test/sql/plxgo.sql +++ b/test/sql/plxgo.sql @@ -246,3 +246,15 @@ CREATE FUNCTION g_panic_msg(who text) RETURNS int LANGUAGE plxgo AS $$ $$; DO $d$ BEGIN PERFORM g_panic_msg(NULL); EXCEPTION WHEN OTHERS THEN RAISE NOTICE 'caught: [%]', SQLERRM; END $d$; + +-- hostile nesting raises a clean error instead of overflowing the C stack +DO $d$ BEGIN + EXECUTE 'CREATE FUNCTION g_deep() RETURNS int LANGUAGE plxgo AS $b$return ' + || repeat('int(', 2000) || '1' || repeat(')', 2000) || E'\n$b$'; +EXCEPTION WHEN OTHERS THEN RAISE WARNING 'caught: %', SQLERRM; END $d$; + +-- a Sprintf directive without a letter verb stays inside the literal +CREATE FUNCTION g_badverb(x text) RETURNS text LANGUAGE plxgo AS $$ + return fmt.Sprintf("%' || version() || '", x) +$$; +SELECT g_badverb('v'); diff --git a/test/sql/plxjs.sql b/test/sql/plxjs.sql index 17218fa..a565368 100644 --- a/test/sql/plxjs.sql +++ b/test/sql/plxjs.sql @@ -165,3 +165,24 @@ SELECT js_hex() AS hex, js_bin() AS bin, js_oct() AS oct, js_hex_us() AS hex_us; CREATE FUNCTION js_flt() RETURNS numeric LANGUAGE plxjs AS $$ return 3.14; $$; CREATE FUNCTION js_exp() RETURNS numeric LANGUAGE plxjs AS $$ return 1.5e3; $$; SELECT js_flt() AS flt, js_exp() AS exp; + +-- a type annotation at the start of a condition is ignored (it used to give a +-- span with no source text and crash the backend) +CREATE FUNCTION js_ann_cond(x int) RETURNS int LANGUAGE plxjs AS $$ + if (/*:: int */ x > 0) { return 1; } + return 0; +$$; +SELECT js_ann_cond(3), js_ann_cond(-3); + +-- finally inside finally is capped (each level doubles the generated body) +DO $d$ DECLARE s text := 'x = 1;'; BEGIN + FOR i IN 1..12 LOOP s := 'try { x = x + 1; } finally { ' || s || ' }'; END LOOP; + EXECUTE 'CREATE FUNCTION js_fin_deep() RETURNS int LANGUAGE plxjs AS $b$ let x /*:: int */ = 0; ' + || s || ' return x; $b$'; +EXCEPTION WHEN OTHERS THEN RAISE WARNING 'caught: %', SQLERRM; END $d$; + +-- a finally body that throws runs once, not again from the error path +DO LANGUAGE plxjs $p$ + try { raise("warning", "body"); } + finally { raise("warning", "finally ran"); throw new Exception("boom"); } +$p$; diff --git a/test/sql/plxpython3.sql b/test/sql/plxpython3.sql index d87b56a..caaafe1 100644 --- a/test/sql/plxpython3.sql +++ b/test/sql/plxpython3.sql @@ -138,3 +138,10 @@ if x is not None: return "?" $$; SELECT py_isnull(NULL), py_isnull(5); + +-- a type annotation inside a call argument list is ignored (it used to give a +-- span with no source text and crash the backend) +DO LANGUAGE plxpython3 $p$ +assert(#:: int + 1 > 0) +$p$; diff --git a/test/sql/plxtsql.sql b/test/sql/plxtsql.sql index 1d9f354..69dac25 100644 --- a/test/sql/plxtsql.sql +++ b/test/sql/plxtsql.sql @@ -147,3 +147,9 @@ CREATE TRIGGER tq_trg_ins BEFORE INSERT ON tq_trg INSERT INTO tq_trg(id, qty, price) VALUES (7, 3, 10); SELECT id, total, tag FROM tq_trg; DROP TABLE tq_trg CASCADE; + +-- hostile nesting raises a clean error instead of overflowing the C stack +DO $d$ BEGIN + EXECUTE 'DO LANGUAGE plxtsql $b$RETURN ' + || repeat('ISNULL(', 2000) || '1' || repeat(', 0)', 2000) || ';$b$'; +EXCEPTION WHEN OTHERS THEN RAISE WARNING 'caught: %', SQLERRM; END $d$; From 8e469d67cbba2cb2de012d4c4c8576e49cedfa4e Mon Sep 17 00:00:00 2001 From: "Joshua D. Drake" Date: Mon, 9 Nov 2026 18:52:34 +0000 Subject: [PATCH 2/4] Fix string-literal breakout, quadratic transpiles and ternary memory growth Second round of the security audit (shared transpiler core): - emit_string_value: escape every literal backslash and force E''. A string ending in a lone backslash inside an interpolation was emitted bare in an E'' literal, escaping its closing quote so the next literal's text ran as code. The synthetic-string scanners no longer step past the end of their input on a trailing backslash. - Locals: index by lower-cased name in a hash table beside the ordered list; the linear lookup made many distinct variables quadratic. - plxpython3 lexer: resume after the blank/comment lines the indentation scan already covered, instead of rescanning the run from every newline (O(K^2), and line numbers were counted twice). - CHECK_FOR_INTERRUPTS in the lexer loop, the expression rewriter and the local table, so statement_timeout and cancel reach a long transpile. - Ternary rewrite: free the condition and both arms after composing the CASE, so a chain of N ternaries no longer holds N copies of its tail. - plxpython3 call(f"..."): reject an f-string procedure name, which was unquoted with its prefix into a broken identifier. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 18 +++++ src/plx_dialect_tsql.c | 2 +- src/plx_engine.h | 3 + src/plx_transpile.c | 134 +++++++++++++++++++++++++++++++++-- test/expected/plxjs.out | 14 ++++ test/expected/plxpython3.out | 13 ++++ test/sql/plxjs.sql | 10 +++ test/sql/plxpython3.sql | 13 ++++ 8 files changed, 202 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 389780f..e90cd75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,22 @@ PUBLIC has USAGE by default). output per level: a 668-byte function stored a 94 MB body, and a slightly deeper one drove a backend to gigabytes of memory. Nesting a finally inside a finally is now limited to 8 levels. +- **String literal escaping its quotes.** A dialect string that ended in a lone + backslash, inside an interpolation (plxjs/plxts `${'...\'}`, plxpython3 + f-strings), was emitted with that backslash bare inside an `E'...'` literal, + where it escaped the closing quote. The next string literal in the body then + ran as code, so a function whose source showed only inert string assignments + could execute arbitrary SQL. Literal backslashes are now always escaped. +- **Quadratic, uninterruptible transpile time.** Local-variable lookup was a + linear scan, so a body with 40,000 distinct variables took 47 seconds; the + plxpython3 lexer rescanned every run of blank lines from each newline, so + 400,000 blank lines took 43 seconds. Neither honoured `statement_timeout`. + Locals are now hashed, blank lines are scanned once, and the lexer, rewriter + and local table check for interrupts. (The same fix stops plxpython3 from + counting blank lines twice in error line numbers.) +- **Memory amplification in chained ternaries.** Each `?:` level kept its own + copy of the rest of the chain, so peak memory was depth times source length + (568 MB for a 1 MB body). Intermediate results are now freed. - **plxgo `fmt.Sprintf` directive without a letter verb.** A `%` followed by a quote consumed half of the escaped `''` pair, so text inside a Go string literal could end up as SQL in the generated body. Such directives now fall @@ -40,6 +56,8 @@ PUBLIC has USAGE by default). - A `finally`/`ensure` body that raised an error on the normal path ran a second time from the error path. It now runs once. +- plxpython3 `call(f"...")` emitted an unterminated quoted identifier. An + f-string procedure name is now rejected. ## [2.0.1] - 2026-08-25 diff --git a/src/plx_dialect_tsql.c b/src/plx_dialect_tsql.c index c3b02e6..1ca2534 100644 --- a/src/plx_dialect_tsql.c +++ b/src/plx_dialect_tsql.c @@ -1534,7 +1534,7 @@ plx_tsql_parse_body(Ctx *cx) tq.pos = save; cx->out.len = 0; cx->out.data[0] = '\0'; - cx->locals = cx->ltail = NULL; + plx_locals_reset(cx); } while (tq_cur(&tq)->kind != TQ_EOF) { diff --git a/src/plx_engine.h b/src/plx_engine.h index 73c9ac7..49c9700 100644 --- a/src/plx_engine.h +++ b/src/plx_engine.h @@ -91,6 +91,8 @@ typedef struct PlxCtx const PlxSurface *surf; StringInfoData out; /* emitted BEGIN..END body */ PlxLocal2 *locals, *ltail; + struct HTAB *lhash; /* lower-cased name -> local (see plx_local_find) */ + bool lhash_off; /* a name too long to index was declared */ int loopdepth; int depth; /* recursion depth guard */ int handlerdepth; /* inside a rescue handler body */ @@ -141,6 +143,7 @@ void plx_emit_try(StringInfo o, int ind, const char *body, int nrescue, const char *arms, const char *ensure_body); PlxLocal2 *plx_local_add(Ctx *cx, const char *name, int len); PlxLocal2 *plx_local_find(Ctx *cx, const char *name, int len); +void plx_locals_reset(Ctx *cx); bool plx_is_param(Ctx *cx, const char *name, int len); /* ---------------------------------------------- dialect front ends (vtable) */ diff --git a/src/plx_transpile.c b/src/plx_transpile.c index 80e2d47..5021b13 100644 --- a/src/plx_transpile.c +++ b/src/plx_transpile.c @@ -26,6 +26,7 @@ #include "mb/pg_wchar.h" #include "miscadmin.h" #include "utils/builtins.h" +#include "utils/hsearch.h" #include "plx.h" #include "plx_int.h" @@ -69,6 +70,7 @@ plx_rewrite_expr(Ctx *cx, const char *s, int len, bool boolctx) char *r; check_stack_depth(); + CHECK_FOR_INTERRUPTS(); if (++cx->depth > PLX_MAX_DEPTH) plx_err(cx, 0, "expression nested too deeply"); r = rewrite_expr_inner(cx, s, len, boolctx); @@ -188,6 +190,7 @@ plx_lex(Ctx *cx) { const char *tokstart = p; + CHECK_FOR_INTERRUPTS(); if (*p == ' ' || *p == '\t' || *p == '\r') { p++; @@ -204,8 +207,18 @@ plx_lex(Ctx *cx) PUSH0(T_NEWLINE); if (indent_mode) { - /* find the next real line's indentation, skipping blanks/comments */ + /* + * Find the next real line's indentation, skipping blanks and + * comments. Resume lexing after the skipped lines (up to the + * first type-annotation line, which still has to be lexed) + * rather than at the next newline: rescanning the run from + * every newline made K blank lines cost O(K^2) and counted + * their line numbers twice. + */ const char *q = p; + const char *resume = p; + int resume_line = line; + bool held = false; int col = 0; for (;;) @@ -219,17 +232,26 @@ plx_lex(Ctx *cx) if (q < end && *q == '\n') { q++; - line++; + if (!held) + { + resume = q; + resume_line++; + } continue; } if (q < end && *q == '#') { + if (surf->type_ann && + strncmp(q, surf->type_ann, strlen(surf->type_ann)) == 0) + held = true; while (q < end && *q != '\n') q++; continue; } break; /* real line, or EOF */ } + p = resume; + line = resume_line; if (q >= end) continue; /* trailing dedents handled at EOF */ if (col > indent_stack[indent_sp]) @@ -564,11 +586,57 @@ plx_is_param(Ctx *cx, const char *name, int len) return false; } +/* + * Locals are kept in declaration order (cx->locals, which DECLARE emission + * walks) and indexed by lower-cased name in cx->lhash. A linear scan made a + * body with many distinct variables quadratic: 40,000 assignments took most + * of a minute, uninterruptibly. Names too long for a key (PostgreSQL would + * truncate them anyway) fall back to the scan. + */ +#define PLX_LKEY 128 + +typedef struct +{ + char key[PLX_LKEY]; + PlxLocal2 *l; +} PlxLocalEnt; + +static bool +plx_local_key(const char *name, int len, char *key) +{ + int i; + + if (len >= PLX_LKEY) + return false; + for (i = 0; i < len; i++) + key[i] = pg_ascii_tolower((unsigned char) name[i]); + memset(key + len, 0, PLX_LKEY - len); + return true; +} + +void +plx_locals_reset(Ctx *cx) +{ + cx->locals = cx->ltail = NULL; + if (cx->lhash) + hash_destroy(cx->lhash); + cx->lhash = NULL; + cx->lhash_off = false; +} + PlxLocal2 * plx_local_find(Ctx *cx, const char *name, int len) { PlxLocal2 *l; + char key[PLX_LKEY]; + CHECK_FOR_INTERRUPTS(); + if (cx->lhash && plx_local_key(name, len, key)) + { + PlxLocalEnt *e = hash_search(cx->lhash, key, HASH_FIND, NULL); + + return e ? e->l : NULL; + } for (l = cx->locals; l; l = l->next) if ((int) strlen(l->name) == len && pg_strncasecmp(l->name, name, len) == 0) return l; @@ -580,12 +648,48 @@ plx_local_add(Ctx *cx, const char *name, int len) { PlxLocal2 *l = palloc0(sizeof(PlxLocal2)); + char key[PLX_LKEY]; + l->name = pnstrdup(name, len); if (cx->ltail) cx->ltail->next = l; else cx->locals = l; cx->ltail = l; + + if (cx->lhash_off) + return l; + if (!cx->lhash) + { + HASHCTL ctl; + + ctl.keysize = PLX_LKEY; + ctl.entrysize = sizeof(PlxLocalEnt); + ctl.hcxt = CurrentMemoryContext; +#if PG_VERSION_NUM >= 140000 + cx->lhash = hash_create("plx locals", 64, &ctl, + HASH_ELEM | HASH_STRINGS | HASH_CONTEXT); +#else + cx->lhash = hash_create("plx locals", 64, &ctl, + HASH_ELEM | HASH_CONTEXT); /* string keys */ +#endif + } + if (plx_local_key(name, len, key)) + { + bool found; + PlxLocalEnt *e = hash_search(cx->lhash, key, HASH_ENTER, &found); + + /* keep the first declaration, as the linear scan did */ + if (!found) + e->l = l; + } + else + { + /* a long name defeats the index: drop it so finds scan the list */ + hash_destroy(cx->lhash); + cx->lhash = NULL; + cx->lhash_off = true; + } return l; } @@ -843,6 +947,19 @@ emit_string_value(Ctx *cx, Tok *tk, StringInfo out) s++; continue; } + /* + * A backslash that reaches here is literal content (a raw string, or + * a lone trailing backslash). Escape it and force the E'' form: left + * bare inside an E'' literal it would escape the closing quote and + * let the next literal's text run on as code. + */ + if (s[0] == '\\') + { + appendStringInfoString(&lit, "\\\\"); + need_e = true; + s++; + continue; + } appendStringInfoChar(&lit, *s); s++; } @@ -992,6 +1109,12 @@ rewrite_expr_inner(Ctx *cx, const char *s, int len, bool boolctx) a = plx_rewrite_expr(cx, s + q + 1, c - q - 1, false); b = plx_rewrite_expr(cx, s + c + 1, len - c - 1, false); r = psprintf("CASE WHEN %s THEN %s ELSE %s END", cond, a, b); + /* free the parts: a chain of ternaries nests one level per + * '?', and keeping every level's copy of the rest of the chain + * made peak memory depth * length */ + pfree(cond); + pfree(a); + pfree(b); return r; } } @@ -1064,7 +1187,7 @@ rewrite_expr_inner(Ctx *cx, const char *s, int len, bool boolctx) while (j < len && s[j] != c) { - if (s[j] == '\\') + if (s[j] == '\\' && j + 1 < len) j++; j++; } @@ -1090,7 +1213,7 @@ rewrite_expr_inner(Ctx *cx, const char *s, int len, bool boolctx) while (j < len && s[j] != q) { - if (s[j] == '\\') + if (s[j] == '\\' && j + 1 < len) j++; j++; } @@ -2214,6 +2337,9 @@ plx_emit_core(Ctx *cx, int a, int b, int ind, bool toplevel) { Tok *pn = &cx->t[as[0]]; + /* an f-string's prefix would be unquoted as part of the name */ + if (pn->fstr) + plx_err(cx, t0->line, "call needs a plain string procedure name, not an f-string"); appendBinaryStringInfo(o, pn->s + 1, pn->len - 2); /* unquote */ } else diff --git a/test/expected/plxjs.out b/test/expected/plxjs.out index 42bb65a..d43c1cd 100644 --- a/test/expected/plxjs.out +++ b/test/expected/plxjs.out @@ -269,3 +269,17 @@ WARNING: body WARNING: finally ran ERROR: boom CONTEXT: PL/pgSQL function inline_code_block line 13 at RAISE +-- a string ending in a lone backslash cannot escape its quotes: the second +-- assignment stays an inert string instead of running set_config +CREATE FUNCTION js_bs_escape() RETURNS text LANGUAGE plxjs AS $q$ +let a = `${'\n\'}`; +let b = " || set_config($x$plx.test_pwned$x$, $x$yes$x$, false)); --"; +return b; +$q$; +SELECT js_bs_escape() LIKE '%set_config%' AS literal_kept, + current_setting('plx.test_pwned', true) IS NULL AS not_run; + literal_kept | not_run +--------------+--------- + t | t +(1 row) + diff --git a/test/expected/plxpython3.out b/test/expected/plxpython3.out index 957a353..f2c6331 100644 --- a/test/expected/plxpython3.out +++ b/test/expected/plxpython3.out @@ -207,3 +207,16 @@ DO LANGUAGE plxpython3 $p$ assert(#:: int 1 > 0) $p$; +-- call() takes a plain string name; an f-string used to emit a broken identifier +DO $d$ BEGIN + EXECUTE E'DO LANGUAGE plxpython3 $b$\ncall(f"noop")\n$b$'; +EXCEPTION WHEN OTHERS THEN RAISE WARNING 'caught: %', SQLERRM; END $d$; +WARNING: caught: plxpython3: call needs a plain string procedure name, not an f-string +-- blank lines and comments are counted once in error line numbers +DO $d$ DECLARE d text; BEGIN + EXECUTE E'DO LANGUAGE plxpython3 $b$\nx = 1\n\n\n# c\n\nclass Foo:\n pass\n$b$'; +EXCEPTION WHEN OTHERS THEN + GET STACKED DIAGNOSTICS d = PG_EXCEPTION_DETAIL; + RAISE WARNING 'caught: % (%)', SQLERRM, d; +END $d$; +WARNING: caught: plxpython3: def is not supported (at plxpython3 source line 7) diff --git a/test/sql/plxjs.sql b/test/sql/plxjs.sql index a565368..9e3730c 100644 --- a/test/sql/plxjs.sql +++ b/test/sql/plxjs.sql @@ -186,3 +186,13 @@ DO LANGUAGE plxjs $p$ try { raise("warning", "body"); } finally { raise("warning", "finally ran"); throw new Exception("boom"); } $p$; + +-- a string ending in a lone backslash cannot escape its quotes: the second +-- assignment stays an inert string instead of running set_config +CREATE FUNCTION js_bs_escape() RETURNS text LANGUAGE plxjs AS $q$ +let a = `${'\n\'}`; +let b = " || set_config($x$plx.test_pwned$x$, $x$yes$x$, false)); --"; +return b; +$q$; +SELECT js_bs_escape() LIKE '%set_config%' AS literal_kept, + current_setting('plx.test_pwned', true) IS NULL AS not_run; diff --git a/test/sql/plxpython3.sql b/test/sql/plxpython3.sql index caaafe1..5d80de5 100644 --- a/test/sql/plxpython3.sql +++ b/test/sql/plxpython3.sql @@ -145,3 +145,16 @@ DO LANGUAGE plxpython3 $p$ assert(#:: int 1 > 0) $p$; + +-- call() takes a plain string name; an f-string used to emit a broken identifier +DO $d$ BEGIN + EXECUTE E'DO LANGUAGE plxpython3 $b$\ncall(f"noop")\n$b$'; +EXCEPTION WHEN OTHERS THEN RAISE WARNING 'caught: %', SQLERRM; END $d$; + +-- blank lines and comments are counted once in error line numbers +DO $d$ DECLARE d text; BEGIN + EXECUTE E'DO LANGUAGE plxpython3 $b$\nx = 1\n\n\n# c\n\nclass Foo:\n pass\n$b$'; +EXCEPTION WHEN OTHERS THEN + GET STACKED DIAGNOSTICS d = PG_EXCEPTION_DETAIL; + RAISE WARNING 'caught: % (%)', SQLERRM, d; +END $d$; From 2717c2ff9699642f12c3d4c968124d01b8624b81 Mon Sep 17 00:00:00 2001 From: "Joshua D. Drake" Date: Mon, 9 Nov 2026 20:01:04 +0000 Subject: [PATCH 3/4] Address review: escape backslash literals, close two DoS bypasses, add tests From the adversarial review of this PR: - standard_conforming_strings: literals emitted by plxgo, plxtsql, plxplsql and plxcobol, and Ruby/PHP single-quoted strings, were plain '...' literals. plpgsql reads the body with the caller's setting, so with it off a trailing backslash swallowed the closing quote and the next literal ran as code. A final pass over the generated body (a small SQL lexer that skips comments, quoted identifiers, dollar quotes, E'' and U&'' strings) now rewrites every ordinary literal containing a backslash as E'...' with the backslash doubled. One place covers every dialect and future emitters. - plxpython3 lexer: a #:: annotation line ended the blank-line scan's resume point but not the scan, so runs of annotation lines were still quadratic. The scan now stops at an annotation line. - Locals index: a name of 128+ bytes disabled the hash and brought back the linear scan. Keys are now downcase_truncate_identifier(), as the server reads an unquoted identifier, so every name is indexed and case folding matches pg_strncasecmp/the server for all encodings; the fallback is gone. - Tests: standard_conforming_strings off across six dialects; timeouts around 60,000 locals after an over-long name, 400,000 blank lines and 40,000 annotation lines; and a nesting depth under the 500-level counter with max_stack_depth = 100kB, which exercises check_stack_depth(). - CHANGELOG: the finally cap bounds depth, not output size. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 23 ++- src/plx_engine.h | 1 - src/plx_transpile.c | 270 +++++++++++++++++++++++++-------- test/expected/plx_features.out | 49 ++++++ test/expected/plxpython3.out | 17 +++ test/expected/plxruby.out | 17 +++ test/expected/plxtsql.out | 8 + test/sql/plx_features.sql | 40 +++++ test/sql/plxpython3.sql | 13 ++ test/sql/plxruby.sql | 13 ++ test/sql/plxtsql.sql | 8 + 11 files changed, 390 insertions(+), 69 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e90cd75..ed38deb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,19 +30,34 @@ PUBLIC has USAGE by default). emitted once per exit path, so a finally nested inside a finally doubled the output per level: a 668-byte function stored a 94 MB body, and a slightly deeper one drove a backend to gigabytes of memory. Nesting a finally inside a - finally is now limited to 8 levels. + finally is now limited to 8 levels. This bounds the depth, not the size: at + the limit the output can still be some 2^8 times the source (a 195 KB body + stores about 144 MB), so a large body can still demand a lot of memory until + the finally body is emitted once. - **String literal escaping its quotes.** A dialect string that ended in a lone backslash, inside an interpolation (plxjs/plxts `${'...\'}`, plxpython3 f-strings), was emitted with that backslash bare inside an `E'...'` literal, where it escaped the closing quote. The next string literal in the body then ran as code, so a function whose source showed only inert string assignments - could execute arbitrary SQL. Literal backslashes are now always escaped. + could execute arbitrary SQL. +- **String literals that depend on `standard_conforming_strings`.** plpgsql + reads a function body with the caller's setting, which any role may change. + Literals that plxgo, plxtsql, plxplsql and plxcobol emit, and Ruby and PHP + single-quoted strings, were written as plain `'...'`, so with the setting off + a literal ending in a backslash swallowed its closing quote and the following + text ran as code; the caller could change what a `SECURITY DEFINER` function + did. The finished body is now rewritten so that every ordinary literal + containing a backslash is an `E'...'` literal with the backslash doubled, + which reads the same under either setting and, with it on, has the value the + original had. This covers every dialect, and SQL passed through verbatim. - **Quadratic, uninterruptible transpile time.** Local-variable lookup was a linear scan, so a body with 40,000 distinct variables took 47 seconds; the plxpython3 lexer rescanned every run of blank lines from each newline, so 400,000 blank lines took 43 seconds. Neither honoured `statement_timeout`. - Locals are now hashed, blank lines are scanned once, and the lexer, rewriter - and local table check for interrupts. (The same fix stops plxpython3 from + Locals are now hashed (keyed as PostgreSQL reads an identifier, down-cased + and truncated to 63 bytes, so no name defeats the index), blank and + annotation lines are scanned once, and the lexer, rewriter and local table + check for interrupts. (The same fix stops plxpython3 from counting blank lines twice in error line numbers.) - **Memory amplification in chained ternaries.** Each `?:` level kept its own copy of the rest of the chain, so peak memory was depth times source length diff --git a/src/plx_engine.h b/src/plx_engine.h index 49c9700..666988f 100644 --- a/src/plx_engine.h +++ b/src/plx_engine.h @@ -92,7 +92,6 @@ typedef struct PlxCtx StringInfoData out; /* emitted BEGIN..END body */ PlxLocal2 *locals, *ltail; struct HTAB *lhash; /* lower-cased name -> local (see plx_local_find) */ - bool lhash_off; /* a name too long to index was declared */ int loopdepth; int depth; /* recursion depth guard */ int handlerdepth; /* inside a rescue handler body */ diff --git a/src/plx_transpile.c b/src/plx_transpile.c index 5021b13..230b2d4 100644 --- a/src/plx_transpile.c +++ b/src/plx_transpile.c @@ -25,6 +25,7 @@ #include "lib/stringinfo.h" #include "mb/pg_wchar.h" #include "miscadmin.h" +#include "parser/scansup.h" #include "utils/builtins.h" #include "utils/hsearch.h" @@ -209,20 +210,23 @@ plx_lex(Ctx *cx) { /* * Find the next real line's indentation, skipping blanks and - * comments. Resume lexing after the skipped lines (up to the - * first type-annotation line, which still has to be lexed) - * rather than at the next newline: rescanning the run from - * every newline made K blank lines cost O(K^2) and counted - * their line numbers twice. + * comments, and resume lexing after the skipped lines rather + * than at the next newline: rescanning the run from every + * newline made K blank lines cost O(K^2) and counted their + * line numbers twice. A type-annotation line ends the scan + * (it has to be lexed); its newline scans on from there, so + * a run of them is linear too. */ const char *q = p; const char *resume = p; int resume_line = line; - bool held = false; + bool ann_line = false; int col = 0; for (;;) { + const char *ls = q; + col = 0; while (*q == ' ' || *q == '\t') { @@ -232,18 +236,19 @@ plx_lex(Ctx *cx) if (q < end && *q == '\n') { q++; - if (!held) - { - resume = q; - resume_line++; - } + resume = q; + resume_line++; continue; } if (q < end && *q == '#') { if (surf->type_ann && strncmp(q, surf->type_ann, strlen(surf->type_ann)) == 0) - held = true; + { + resume = ls; + ann_line = true; + break; + } while (q < end && *q != '\n') q++; continue; @@ -252,8 +257,9 @@ plx_lex(Ctx *cx) } p = resume; line = resume_line; - if (q >= end) - continue; /* trailing dedents handled at EOF */ + if (ann_line || q >= end) + continue; /* indentation is taken at the next real line; + * trailing dedents are handled at EOF */ if (col > indent_stack[indent_sp]) { /* @@ -588,30 +594,29 @@ plx_is_param(Ctx *cx, const char *name, int len) /* * Locals are kept in declaration order (cx->locals, which DECLARE emission - * walks) and indexed by lower-cased name in cx->lhash. A linear scan made a - * body with many distinct variables quadratic: 40,000 assignments took most - * of a minute, uninterruptibly. Names too long for a key (PostgreSQL would - * truncate them anyway) fall back to the scan. + * walks) and indexed in cx->lhash. A linear scan made a body with many + * distinct variables quadratic: 40,000 assignments took most of a minute. + * + * The key is the name as PostgreSQL itself reads an unquoted identifier: + * down-cased and truncated to NAMEDATALEN - 1 bytes (downcase_truncate_ + * identifier). Two names that plpgsql would treat as one variable therefore + * share an entry, any name can be indexed (an over-long one no longer forces a + * fallback scan), and case folding matches the server's for every encoding. */ -#define PLX_LKEY 128 - typedef struct { - char key[PLX_LKEY]; + char key[NAMEDATALEN]; PlxLocal2 *l; } PlxLocalEnt; -static bool +static void plx_local_key(const char *name, int len, char *key) { - int i; + char *k = downcase_truncate_identifier(name, len, false); - if (len >= PLX_LKEY) - return false; - for (i = 0; i < len; i++) - key[i] = pg_ascii_tolower((unsigned char) name[i]); - memset(key + len, 0, PLX_LKEY - len); - return true; + memset(key, 0, NAMEDATALEN); + strlcpy(key, k, NAMEDATALEN); + pfree(k); } void @@ -621,34 +626,29 @@ plx_locals_reset(Ctx *cx) if (cx->lhash) hash_destroy(cx->lhash); cx->lhash = NULL; - cx->lhash_off = false; } PlxLocal2 * plx_local_find(Ctx *cx, const char *name, int len) { - PlxLocal2 *l; - char key[PLX_LKEY]; + char key[NAMEDATALEN]; + PlxLocalEnt *e; CHECK_FOR_INTERRUPTS(); - if (cx->lhash && plx_local_key(name, len, key)) - { - PlxLocalEnt *e = hash_search(cx->lhash, key, HASH_FIND, NULL); - - return e ? e->l : NULL; - } - for (l = cx->locals; l; l = l->next) - if ((int) strlen(l->name) == len && pg_strncasecmp(l->name, name, len) == 0) - return l; - return NULL; + if (!cx->lhash) + return NULL; + plx_local_key(name, len, key); + e = hash_search(cx->lhash, key, HASH_FIND, NULL); + return e ? e->l : NULL; } PlxLocal2 * plx_local_add(Ctx *cx, const char *name, int len) { PlxLocal2 *l = palloc0(sizeof(PlxLocal2)); - - char key[PLX_LKEY]; + char key[NAMEDATALEN]; + bool found; + PlxLocalEnt *e; l->name = pnstrdup(name, len); if (cx->ltail) @@ -657,13 +657,11 @@ plx_local_add(Ctx *cx, const char *name, int len) cx->locals = l; cx->ltail = l; - if (cx->lhash_off) - return l; if (!cx->lhash) { HASHCTL ctl; - ctl.keysize = PLX_LKEY; + ctl.keysize = NAMEDATALEN; ctl.entrysize = sizeof(PlxLocalEnt); ctl.hcxt = CurrentMemoryContext; #if PG_VERSION_NUM >= 140000 @@ -674,22 +672,10 @@ plx_local_add(Ctx *cx, const char *name, int len) HASH_ELEM | HASH_CONTEXT); /* string keys */ #endif } - if (plx_local_key(name, len, key)) - { - bool found; - PlxLocalEnt *e = hash_search(cx->lhash, key, HASH_ENTER, &found); - - /* keep the first declaration, as the linear scan did */ - if (!found) - e->l = l; - } - else - { - /* a long name defeats the index: drop it so finds scan the list */ - hash_destroy(cx->lhash); - cx->lhash = NULL; - cx->lhash_off = true; - } + plx_local_key(name, len, key); + e = hash_search(cx->lhash, key, HASH_ENTER, &found); + if (!found) + e->l = l; /* keep the first declaration */ return l; } @@ -2830,6 +2816,162 @@ djb2(const char *s) return h; } +static bool +sql_ident_char(char c) +{ + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || c == '_' || c == '$' || IS_HIGHBIT_SET(c); +} + +/* + * Rewrite every standard string literal in the generated plpgsql that contains + * a backslash as an escape string: 'C:\' becomes E'C:\\'. + * + * plpgsql reads a function body with the caller's standard_conforming_strings, + * which any role may set. With it off, a backslash in an ordinary '...' + * literal is an escape, so a literal ending in one swallows its closing quote + * and the following text runs as code; a SECURITY DEFINER function's + * behaviour would then depend on its caller. An E'...' literal reads the same + * either way, and with the setting on (the default) the rewritten literal has + * exactly the value of the original. Doing this once over the finished body + * covers every dialect's literal emitters, and SQL the author passed through. + * + * A small SQL lexer: skips comments (nested block comments too), quoted + * identifiers, dollar-quoted strings, E'' strings (already safe) and U&'' + * strings (where a backslash introduces a Unicode escape). + */ +static char * +escape_backslash_literals(const char *s) +{ + StringInfoData o; + const char *p = s; + + if (!strchr(s, '\\')) + return (char *) s; + initStringInfo(&o); + while (*p) + { + const char *q; + + if (p[0] == '-' && p[1] == '-') + { + q = strchr(p, '\n'); + q = q ? q + 1 : p + strlen(p); + appendBinaryStringInfo(&o, p, q - p); + p = q; + continue; + } + if (p[0] == '/' && p[1] == '*') + { + int depth = 1; + + q = p + 2; + while (*q && depth > 0) + { + if (q[0] == '/' && q[1] == '*') + { + depth++; + q += 2; + } + else if (q[0] == '*' && q[1] == '/') + { + depth--; + q += 2; + } + else + q++; + } + appendBinaryStringInfo(&o, p, q - p); + p = q; + continue; + } + if (p[0] == '"') + { + q = p + 1; + while (*q && !(q[0] == '"' && q[1] != '"')) + q += (q[0] == '"') ? 2 : 1; + if (*q) + q++; + appendBinaryStringInfo(&o, p, q - p); + p = q; + continue; + } + if (p[0] == '$' && (p == s || !sql_ident_char(p[-1]))) + { + /* $tag$ ... $tag$; the tag cannot start with a digit ($1 is a param) */ + const char *t = p + 1; + + if (*t != '$' && ((*t >= 'a' && *t <= 'z') || (*t >= 'A' && *t <= 'Z') || + *t == '_' || IS_HIGHBIT_SET(*t))) + while (sql_ident_char(*t) && *t != '$') + t++; + if (*t == '$') + { + int taglen = (int) (t - p) + 1; + char *tag = pnstrdup(p, taglen); + const char *end = strstr(t + 1, tag); + + q = end ? end + taglen : p + strlen(p); + appendBinaryStringInfo(&o, p, q - p); + p = q; + pfree(tag); + continue; + } + } + if (p[0] == '\'') + { + bool estr = p > s && (p[-1] == 'E' || p[-1] == 'e') && + (p - 1 == s || !sql_ident_char(p[-2])); + bool ustr = p - 1 > s && p[-1] == '&' && + (p[-2] == 'U' || p[-2] == 'u') && + (p - 2 == s || !sql_ident_char(p[-3])); + /* B'..', X'..', N'..' and the like: leave other prefixes alone */ + bool prefixed = !estr && !ustr && p > s && sql_ident_char(p[-1]); + bool has_bs = false; + + q = p + 1; + while (*q) + { + if (estr && q[0] == '\\' && q[1]) + q += 2; + else if (q[0] == '\'' && q[1] == '\'') + q += 2; + else if (q[0] == '\'') + break; + else + { + if (q[0] == '\\') + has_bs = true; + q++; + } + } + if (has_bs && !estr && !ustr && !prefixed) + { + const char *c; + + appendStringInfoString(&o, "E'"); + for (c = p + 1; c < q; c++) + { + if (*c == '\\') + appendStringInfoChar(&o, '\\'); + appendStringInfoChar(&o, *c); + } + } + else + appendBinaryStringInfo(&o, p, q - p); + if (*q) + { + appendStringInfoChar(&o, '\''); + q++; + } + p = q; + continue; + } + appendStringInfoChar(&o, *p++); + } + return o.data; +} + char * plx_transpile(const char *body, const PlxFuncMeta *meta, const PlxSurface *surf, MemoryContext scratch) @@ -2889,5 +3031,5 @@ plx_transpile(const char *body, const PlxFuncMeta *meta, const PlxSurface *surf, } appendStringInfo(&asm_, "/*plx-orig:b64$%s$plx-orig*/\n", b64_encode_body(body)); - return asm_.data; + return escape_backslash_literals(asm_.data); } diff --git a/test/expected/plx_features.out b/test/expected/plx_features.out index 859077e..b0f561f 100644 --- a/test/expected/plx_features.out +++ b/test/expected/plx_features.out @@ -233,3 +233,52 @@ SELECT f_strict() AS strict_no_row; -1 (1 row) +-- a literal with a backslash reads the same whatever standard_conforming_strings +-- the caller uses: generated code writes it as E'...', so with the setting off +-- the backslash cannot swallow the closing quote and run the next literal as code +CREATE FUNCTION lit_go() RETURNS text LANGUAGE plxgo AS $P$ +a := "C:\\" +b := "; RAISE EXCEPTION $$go: injected$$; --" +return a +$P$; +CREATE FUNCTION lit_rb() RETURNS text LANGUAGE plxruby AS $P$ +a = 'C:\\' +b = "; RAISE EXCEPTION $$rb: injected$$; --" +return a +$P$; +CREATE FUNCTION lit_php() RETURNS text LANGUAGE plxphp AS $P$ +$a = 'C:\\'; +$b = '; RAISE EXCEPTION $$php: injected$$; --'; +return $a; +$P$; +CREATE FUNCTION lit_tsql() RETURNS varchar LANGUAGE plxtsql AS $P$ +DECLARE @a varchar(10) = 'C:\'; +DECLARE @b varchar(80) = '; RAISE EXCEPTION $$tsql: injected$$; --'; +RETURN @a; +$P$; +CREATE FUNCTION lit_plsql() RETURNS text LANGUAGE plxplsql AS $P$ +DECLARE a text := 'C:\'; b text := '; RAISE EXCEPTION $$plsql: injected$$; --'; +BEGIN RETURN a; END; +$P$; +CREATE FUNCTION lit_cob() RETURNS text LANGUAGE plxcobol AS $P$ +DATA DIVISION. +WORKING-STORAGE SECTION. +01 A PIC X(10) VALUE "C:\". +01 B PIC X(60) VALUE "; RAISE EXCEPTION $$cob: injected$$; --". +PROCEDURE DIVISION. + RETURN A. +$P$; +SET standard_conforming_strings = off; +SELECT lit_go(), lit_rb(), lit_php(), lit_tsql(), lit_plsql(), lit_cob(); + lit_go | lit_rb | lit_php | lit_tsql | lit_plsql | lit_cob +--------+--------+---------+----------+-----------+--------- + C:\ | C:\ | C:\ | C:\ | C:\ | C:\ +(1 row) + +RESET standard_conforming_strings; +SELECT lit_go(), lit_rb(), lit_php(), lit_tsql(), lit_plsql(), lit_cob(); + lit_go | lit_rb | lit_php | lit_tsql | lit_plsql | lit_cob +--------+--------+---------+----------+-----------+--------- + C:\ | C:\ | C:\ | C:\ | C:\ | C:\ +(1 row) + diff --git a/test/expected/plxpython3.out b/test/expected/plxpython3.out index f2c6331..9014447 100644 --- a/test/expected/plxpython3.out +++ b/test/expected/plxpython3.out @@ -220,3 +220,20 @@ EXCEPTION WHEN OTHERS THEN RAISE WARNING 'caught: % (%)', SQLERRM, d; END $d$; WARNING: caught: plxpython3: def is not supported (at plxpython3 source line 7) +-- runs of blank or annotation lines lex in linear time (a regression takes tens +-- of seconds and trips the timeout, which the handlers below do not catch) +SET statement_timeout = '20s'; +DO $d$ BEGIN + EXECUTE E'CREATE FUNCTION py_blank_run() RETURNS int LANGUAGE plxpython3 AS $b$\nx = 1\n' + || repeat(E'\n', 400000) || E'return x\n$b$'; +END $d$; +SELECT py_blank_run(); + py_blank_run +-------------- + 1 +(1 row) + +DO $d$ BEGIN + EXECUTE E'DO LANGUAGE plxpython3 $b$\nx = 1\n' || repeat(E'#:: int\n', 40000) || E'$b$'; +EXCEPTION WHEN syntax_error OR feature_not_supported THEN NULL; END $d$; +RESET statement_timeout; diff --git a/test/expected/plxruby.out b/test/expected/plxruby.out index c18c4ed..43327d8 100644 --- a/test/expected/plxruby.out +++ b/test/expected/plxruby.out @@ -398,3 +398,20 @@ SELECT rb_hex(); 255 (1 row) +-- many distinct locals transpile in linear time, even after an over-long name +-- (which used to disable the index); a regression takes tens of seconds +SET statement_timeout = '20s'; +DO $d$ BEGIN + EXECUTE 'CREATE FUNCTION rb_many_locals() RETURNS int LANGUAGE plxruby AS $b$' + || 'v' || repeat('a', 130) || E' = 0\n' + || (SELECT string_agg(format('v%s = %s', i, i), E'\n') FROM generate_series(1, 60000) AS i) + || E'\nreturn 1\n$b$'; +END $d$; +-- (not called: plpgsql's own compile of 60,000 variables is slow, and is not plx's) +SELECT count(*) FROM pg_proc WHERE proname = 'rb_many_locals'; + count +------- + 1 +(1 row) + +RESET statement_timeout; diff --git a/test/expected/plxtsql.out b/test/expected/plxtsql.out index 628c11d..b2699ef 100644 --- a/test/expected/plxtsql.out +++ b/test/expected/plxtsql.out @@ -211,3 +211,11 @@ DO $d$ BEGIN || repeat('ISNULL(', 2000) || '1' || repeat(', 0)', 2000) || ';$b$'; EXCEPTION WHEN OTHERS THEN RAISE WARNING 'caught: %', SQLERRM; END $d$; WARNING: caught: plxtsql: expression nested too deeply +-- below the 500-level counter, a small stack is guarded by check_stack_depth() +SET max_stack_depth = '100kB'; +DO $d$ BEGIN + EXECUTE 'DO LANGUAGE plxtsql $b$RETURN ' + || repeat('ISNULL(', 450) || '1' || repeat(', 0)', 450) || ';$b$'; +EXCEPTION WHEN OTHERS THEN RAISE WARNING 'caught: %', SQLERRM; END $d$; +WARNING: caught: stack depth limit exceeded +RESET max_stack_depth; diff --git a/test/sql/plx_features.sql b/test/sql/plx_features.sql index 6e2699d..a5ea987 100644 --- a/test/sql/plx_features.sql +++ b/test/sql/plx_features.sql @@ -143,3 +143,43 @@ rescue => e end $$; SELECT f_strict() AS strict_no_row; + +-- a literal with a backslash reads the same whatever standard_conforming_strings +-- the caller uses: generated code writes it as E'...', so with the setting off +-- the backslash cannot swallow the closing quote and run the next literal as code +CREATE FUNCTION lit_go() RETURNS text LANGUAGE plxgo AS $P$ +a := "C:\\" +b := "; RAISE EXCEPTION $$go: injected$$; --" +return a +$P$; +CREATE FUNCTION lit_rb() RETURNS text LANGUAGE plxruby AS $P$ +a = 'C:\\' +b = "; RAISE EXCEPTION $$rb: injected$$; --" +return a +$P$; +CREATE FUNCTION lit_php() RETURNS text LANGUAGE plxphp AS $P$ +$a = 'C:\\'; +$b = '; RAISE EXCEPTION $$php: injected$$; --'; +return $a; +$P$; +CREATE FUNCTION lit_tsql() RETURNS varchar LANGUAGE plxtsql AS $P$ +DECLARE @a varchar(10) = 'C:\'; +DECLARE @b varchar(80) = '; RAISE EXCEPTION $$tsql: injected$$; --'; +RETURN @a; +$P$; +CREATE FUNCTION lit_plsql() RETURNS text LANGUAGE plxplsql AS $P$ +DECLARE a text := 'C:\'; b text := '; RAISE EXCEPTION $$plsql: injected$$; --'; +BEGIN RETURN a; END; +$P$; +CREATE FUNCTION lit_cob() RETURNS text LANGUAGE plxcobol AS $P$ +DATA DIVISION. +WORKING-STORAGE SECTION. +01 A PIC X(10) VALUE "C:\". +01 B PIC X(60) VALUE "; RAISE EXCEPTION $$cob: injected$$; --". +PROCEDURE DIVISION. + RETURN A. +$P$; +SET standard_conforming_strings = off; +SELECT lit_go(), lit_rb(), lit_php(), lit_tsql(), lit_plsql(), lit_cob(); +RESET standard_conforming_strings; +SELECT lit_go(), lit_rb(), lit_php(), lit_tsql(), lit_plsql(), lit_cob(); diff --git a/test/sql/plxpython3.sql b/test/sql/plxpython3.sql index 5d80de5..e057e15 100644 --- a/test/sql/plxpython3.sql +++ b/test/sql/plxpython3.sql @@ -158,3 +158,16 @@ EXCEPTION WHEN OTHERS THEN GET STACKED DIAGNOSTICS d = PG_EXCEPTION_DETAIL; RAISE WARNING 'caught: % (%)', SQLERRM, d; END $d$; + +-- runs of blank or annotation lines lex in linear time (a regression takes tens +-- of seconds and trips the timeout, which the handlers below do not catch) +SET statement_timeout = '20s'; +DO $d$ BEGIN + EXECUTE E'CREATE FUNCTION py_blank_run() RETURNS int LANGUAGE plxpython3 AS $b$\nx = 1\n' + || repeat(E'\n', 400000) || E'return x\n$b$'; +END $d$; +SELECT py_blank_run(); +DO $d$ BEGIN + EXECUTE E'DO LANGUAGE plxpython3 $b$\nx = 1\n' || repeat(E'#:: int\n', 40000) || E'$b$'; +EXCEPTION WHEN syntax_error OR feature_not_supported THEN NULL; END $d$; +RESET statement_timeout; diff --git a/test/sql/plxruby.sql b/test/sql/plxruby.sql index 37f5d2c..65a76db 100644 --- a/test/sql/plxruby.sql +++ b/test/sql/plxruby.sql @@ -282,3 +282,16 @@ CREATE FUNCTION rb_hex() RETURNS int LANGUAGE plxruby AS $$ return 0xff $$; SELECT rb_hex(); + +-- many distinct locals transpile in linear time, even after an over-long name +-- (which used to disable the index); a regression takes tens of seconds +SET statement_timeout = '20s'; +DO $d$ BEGIN + EXECUTE 'CREATE FUNCTION rb_many_locals() RETURNS int LANGUAGE plxruby AS $b$' + || 'v' || repeat('a', 130) || E' = 0\n' + || (SELECT string_agg(format('v%s = %s', i, i), E'\n') FROM generate_series(1, 60000) AS i) + || E'\nreturn 1\n$b$'; +END $d$; +-- (not called: plpgsql's own compile of 60,000 variables is slow, and is not plx's) +SELECT count(*) FROM pg_proc WHERE proname = 'rb_many_locals'; +RESET statement_timeout; diff --git a/test/sql/plxtsql.sql b/test/sql/plxtsql.sql index 69dac25..e95cd2c 100644 --- a/test/sql/plxtsql.sql +++ b/test/sql/plxtsql.sql @@ -153,3 +153,11 @@ DO $d$ BEGIN EXECUTE 'DO LANGUAGE plxtsql $b$RETURN ' || repeat('ISNULL(', 2000) || '1' || repeat(', 0)', 2000) || ';$b$'; EXCEPTION WHEN OTHERS THEN RAISE WARNING 'caught: %', SQLERRM; END $d$; + +-- below the 500-level counter, a small stack is guarded by check_stack_depth() +SET max_stack_depth = '100kB'; +DO $d$ BEGIN + EXECUTE 'DO LANGUAGE plxtsql $b$RETURN ' + || repeat('ISNULL(', 450) || '1' || repeat(', 0)', 450) || ';$b$'; +EXCEPTION WHEN OTHERS THEN RAISE WARNING 'caught: %', SQLERRM; END $d$; +RESET max_stack_depth; From 223aae11bf366e851851a8cca4383d2c07bad05f Mon Sep 17 00:00:00 2001 From: "Joshua D. Drake" Date: Mon, 9 Nov 2026 23:51:11 +0000 Subject: [PATCH 4/4] test: put the type annotation after the initializer in js_fin_deep "let x /*:: int */ = 0;" drops the "= 0" (#20 makes that form an error). The test's result does not depend on it, but it should use the documented form so it keeps passing once #20 is merged. Co-Authored-By: Claude Opus 5.5 --- test/expected/plxjs.out | 2 +- test/sql/plxjs.sql | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/expected/plxjs.out b/test/expected/plxjs.out index d43c1cd..82ccd88 100644 --- a/test/expected/plxjs.out +++ b/test/expected/plxjs.out @@ -256,7 +256,7 @@ SELECT js_ann_cond(3), js_ann_cond(-3); -- finally inside finally is capped (each level doubles the generated body) DO $d$ DECLARE s text := 'x = 1;'; BEGIN FOR i IN 1..12 LOOP s := 'try { x = x + 1; } finally { ' || s || ' }'; END LOOP; - EXECUTE 'CREATE FUNCTION js_fin_deep() RETURNS int LANGUAGE plxjs AS $b$ let x /*:: int */ = 0; ' + EXECUTE 'CREATE FUNCTION js_fin_deep() RETURNS int LANGUAGE plxjs AS $b$ let x = 0 /*:: int */; ' || s || ' return x; $b$'; EXCEPTION WHEN OTHERS THEN RAISE WARNING 'caught: %', SQLERRM; END $d$; WARNING: caught: plxjs: finally/ensure blocks nested too deeply inside one another diff --git a/test/sql/plxjs.sql b/test/sql/plxjs.sql index 9e3730c..0c0f1f8 100644 --- a/test/sql/plxjs.sql +++ b/test/sql/plxjs.sql @@ -177,7 +177,7 @@ SELECT js_ann_cond(3), js_ann_cond(-3); -- finally inside finally is capped (each level doubles the generated body) DO $d$ DECLARE s text := 'x = 1;'; BEGIN FOR i IN 1..12 LOOP s := 'try { x = x + 1; } finally { ' || s || ' }'; END LOOP; - EXECUTE 'CREATE FUNCTION js_fin_deep() RETURNS int LANGUAGE plxjs AS $b$ let x /*:: int */ = 0; ' + EXECUTE 'CREATE FUNCTION js_fin_deep() RETURNS int LANGUAGE plxjs AS $b$ let x = 0 /*:: int */; ' || s || ' return x; $b$'; EXCEPTION WHEN OTHERS THEN RAISE WARNING 'caught: %', SQLERRM; END $d$;