diff --git a/CHANGELOG.md b/CHANGELOG.md index 1930387..ed38deb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,76 @@ 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. 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. +- **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 (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 + (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 + 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. +- plxpython3 `call(f"...")` emitted an unterminated quoted identifier. An + f-string procedure name is now rejected. + ## [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..1ca2534 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) @@ -1519,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 434be02..666988f 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. */ @@ -89,9 +91,11 @@ 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) */ 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,8 +137,12 @@ 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); +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_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..230b2d4 100644 --- a/src/plx_transpile.c +++ b/src/plx_transpile.c @@ -25,7 +25,9 @@ #include "lib/stringinfo.h" #include "mb/pg_wchar.h" #include "miscadmin.h" +#include "parser/scansup.h" #include "utils/builtins.h" +#include "utils/hsearch.h" #include "plx.h" #include "plx_int.h" @@ -69,6 +71,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 +191,7 @@ plx_lex(Ctx *cx) { const char *tokstart = p; + CHECK_FOR_INTERRUPTS(); if (*p == ' ' || *p == '\t' || *p == '\r') { p++; @@ -204,12 +208,25 @@ 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, 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 ann_line = false; int col = 0; for (;;) { + const char *ls = q; + col = 0; while (*q == ' ' || *q == '\t') { @@ -219,19 +236,30 @@ plx_lex(Ctx *cx) if (q < end && *q == '\n') { q++; - line++; + resume = q; + resume_line++; continue; } if (q < end && *q == '#') { + if (surf->type_ann && + strncmp(q, surf->type_ann, strlen(surf->type_ann)) == 0) + { + resume = ls; + ann_line = true; + break; + } while (q < end && *q != '\n') q++; continue; } break; /* real line, or EOF */ } - if (q >= end) - continue; /* trailing dedents handled at EOF */ + p = resume; + line = resume_line; + 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]) { /* @@ -564,21 +592,63 @@ 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 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. + */ +typedef struct +{ + char key[NAMEDATALEN]; + PlxLocal2 *l; +} PlxLocalEnt; + +static void +plx_local_key(const char *name, int len, char *key) +{ + char *k = downcase_truncate_identifier(name, len, false); + + memset(key, 0, NAMEDATALEN); + strlcpy(key, k, NAMEDATALEN); + pfree(k); +} + +void +plx_locals_reset(Ctx *cx) +{ + cx->locals = cx->ltail = NULL; + if (cx->lhash) + hash_destroy(cx->lhash); + cx->lhash = NULL; +} + PlxLocal2 * plx_local_find(Ctx *cx, const char *name, int len) { - PlxLocal2 *l; + char key[NAMEDATALEN]; + PlxLocalEnt *e; - 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; + CHECK_FOR_INTERRUPTS(); + 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[NAMEDATALEN]; + bool found; + PlxLocalEnt *e; l->name = pnstrdup(name, len); if (cx->ltail) @@ -586,6 +656,26 @@ plx_local_add(Ctx *cx, const char *name, int len) else cx->locals = l; cx->ltail = l; + + if (!cx->lhash) + { + HASHCTL ctl; + + ctl.keysize = NAMEDATALEN; + 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 + } + 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; } @@ -843,6 +933,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 +1095,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 +1173,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 +1199,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++; } @@ -1471,6 +1580,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 +1630,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 */ @@ -2143,6 +2323,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 @@ -2633,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) @@ -2692,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/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..82ccd88 100644 --- a/test/expected/plxjs.out +++ b/test/expected/plxjs.out @@ -241,3 +241,45 @@ 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 = 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 +-- 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 +-- 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 1e0359f..9014447 100644 --- a/test/expected/plxpython3.out +++ b/test/expected/plxpython3.out @@ -201,3 +201,39 @@ 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$; +-- 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) +-- 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 daa328c..b2699ef 100644 --- a/test/expected/plxtsql.out +++ b/test/expected/plxtsql.out @@ -205,3 +205,17 @@ 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 +-- 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/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..0c0f1f8 100644 --- a/test/sql/plxjs.sql +++ b/test/sql/plxjs.sql @@ -165,3 +165,34 @@ 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 = 0 /*:: int */; ' + || 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$; + +-- 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 d87b56a..e057e15 100644 --- a/test/sql/plxpython3.sql +++ b/test/sql/plxpython3.sql @@ -138,3 +138,36 @@ 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$; + +-- 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$; + +-- 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 1d9f354..e95cd2c 100644 --- a/test/sql/plxtsql.sql +++ b/test/sql/plxtsql.sql @@ -147,3 +147,17 @@ 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$; + +-- 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;