From b38f69f1f75d6e7942e406125966a632b9dc2cd6 Mon Sep 17 00:00:00 2001 From: "Joshua D. Drake" Date: Mon, 9 Nov 2026 23:49:38 +0000 Subject: [PATCH] Accept only a type name as a local's type; reject an annotation before an initializer A local's type is spliced into DECLARE, where plpgsql reads up to the ';'. An annotation such as "int := f()" or "int DEFAULT f()" therefore became an initializer that ran on entry while the source showed only a type (in plxjs, only a comment), and "int; BEGIN ... END; DECLARE y int" became statements. Before a type reaches DECLARE it must now be name%TYPE / name%ROWTYPE or a type name the SQL type-name grammar accepts (typeStringToTypeName, which is what plpgsql's own parse_datatype uses), with no comment in it. The check is at the one place DECLARE is assembled, so it covers annotations, plxts types, plxgo and plxtsql declarations and the plxcobol TYPE clause. Also: in plxjs and plxphp, "let n /*:: bigint */ = 3;" was treated as a bare annotation and the "= 3" was dropped, leaving n NULL. It is now rejected with a hint to put the annotation after the value. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 20 +++++ doc/plxcobol.md | 3 +- doc/plxjs.md | 7 +- doc/plxphp.md | 7 +- doc/plxpython3.md | 4 +- doc/plxruby.md | 5 +- src/plx_transpile.c | 145 +++++++++++++++++++++++++++++++++++ test/expected/plx_errors.out | 74 ++++++++++++++++++ test/sql/plx_errors.sql | 56 ++++++++++++++ 9 files changed, 313 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1930387..0933b05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,26 @@ 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 + +- A local variable's type is spliced into the plpgsql `DECLARE` section, and + plpgsql reads a declaration up to its `;`. A type annotation could therefore + carry an initializer or more statements. For example, `x #:: int := f()` or + `let x /*:: int DEFAULT f() */;` ran `f()` on entry, while the source showed + only a type, and in plxjs only a comment. Every type that reaches `DECLARE` + (annotations, plxts types, plxgo and plxtsql declarations, the plxcobol `TYPE` + clause) must now be a type name accepted by the SQL type-name grammar, or + `name%TYPE` / `name%ROWTYPE`. Anything else is rejected at `CREATE FUNCTION`. + +### Fixed + +- In plxjs and plxphp, a block annotation placed before an initializer + (`let n /*:: bigint */ = 3;`) silently dropped the initializer, so `n` was + NULL. That form is now rejected with a hint to write + `let n = 3 /*:: bigint */;`. + ## [2.0.1] - 2026-08-25 Packaging only. **The extension is unchanged at 2.0.0**, so there is no diff --git a/doc/plxcobol.md b/doc/plxcobol.md index ff6de2c..37eb23a 100644 --- a/doc/plxcobol.md +++ b/doc/plxcobol.md @@ -62,7 +62,8 @@ WORKING-STORAGE SECTION. ``` For types a PICTURE cannot express (`%TYPE`, `%ROWTYPE`, `RECORD`, `refcursor`), -use a `TYPE` clause, whose text is emitted verbatim as the plpgsql type: +use a `TYPE` clause, whose text becomes the plpgsql type (it must be a type name +and nothing else): ```sql 01 WS-EMP TYPE emp%ROWTYPE. diff --git a/doc/plxjs.md b/doc/plxjs.md index daf5b7e..6a7287f 100644 --- a/doc/plxjs.md +++ b/doc/plxjs.md @@ -32,7 +32,8 @@ expressions are passed through to plpgsql and SQL unchanged. Declare locals with `let`, `const`, or `var`; the keyword is removed in the generated plpgsql. plx infers the type from the first value when it is an integer, numeric, text, or boolean literal. Otherwise annotate the variable with -a `/*:: type */` comment before the `;`. +a `/*:: type */` comment before the `;`. An annotation before an initializer +(`let n /*:: bigint */ = 3;`) is rejected; write `let n = 3 /*:: bigint */;`. ```sql let n = 42; // inferred integer @@ -41,7 +42,9 @@ let amount /*:: numeric */; amount = principal * rate; ``` -The annotation text is emitted verbatim, so `%TYPE` and `%ROWTYPE` work: +The annotation becomes the plpgsql type. It must be a type name and nothing +else (plx checks this at `CREATE FUNCTION` time), and `%TYPE` and `%ROWTYPE` +work: ```sql let e /*:: emp%ROWTYPE */; diff --git a/doc/plxphp.md b/doc/plxphp.md index 8bbd8be..98bb2e7 100644 --- a/doc/plxphp.md +++ b/doc/plxphp.md @@ -31,7 +31,8 @@ through to plpgsql and SQL unchanged. Assignment creates a local. plx infers the type from the first value when it is an integer, numeric, text, or boolean literal. Otherwise annotate the variable -with a `/*:: type */` comment placed before the `;`. +with a `/*:: type */` comment placed before the `;`. An annotation before an initializer +(`$n /*:: bigint */ = 3;`) is rejected; write `$n = 3 /*:: bigint */;`. ```sql $n = 42; // inferred integer @@ -40,7 +41,9 @@ $amount /*:: numeric */; $amount = $principal * $rate; ``` -The annotation text is emitted verbatim, so `%TYPE` and `%ROWTYPE` work: +The annotation becomes the plpgsql type. It must be a type name and nothing +else (plx checks this at `CREATE FUNCTION` time), and `%TYPE` and `%ROWTYPE` +work: ```sql $e /*:: emp%ROWTYPE */; diff --git a/doc/plxpython3.md b/doc/plxpython3.md index 71bcb48..34021cb 100644 --- a/doc/plxpython3.md +++ b/doc/plxpython3.md @@ -41,7 +41,9 @@ amount #:: numeric amount = principal * rate ``` -The annotation text is emitted verbatim, so `%TYPE` and `%ROWTYPE` work: +The annotation becomes the plpgsql type. It must be a type name and nothing +else (plx checks this at `CREATE FUNCTION` time), and `%TYPE` and `%ROWTYPE` +work: ```sql e #:: emp%ROWTYPE diff --git a/doc/plxruby.md b/doc/plxruby.md index 22a2c79..327e6f1 100644 --- a/doc/plxruby.md +++ b/doc/plxruby.md @@ -42,8 +42,9 @@ amount = principal * rate A first assignment of a constant literal at the top level is folded into the declaration, so `n = 42` becomes `DECLARE n integer := 42;`. -The annotation text is emitted verbatim as the plpgsql type, so `%TYPE` and -`%ROWTYPE` work: +The annotation becomes the plpgsql type. It must be a type name and nothing +else (plx checks this at `CREATE FUNCTION` time), and `%TYPE` and `%ROWTYPE` +work: ```sql e #:: emp%ROWTYPE diff --git a/src/plx_transpile.c b/src/plx_transpile.c index 0a23018..c7edb65 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/parse_type.h" #include "utils/builtins.h" #include "plx.h" @@ -2257,6 +2258,11 @@ plx_emit_core(Ctx *cx, int a, int b, int ind, bool toplevel) if (plx_is_param(cx, t0->s, t0->len)) plx_err(cx, t0->line, "cannot annotate parameter \"%.*s\"", t0->len, t0->s); + /* a block annotation before "= 3" used to drop the "= 3" silently */ + if (a + 2 < b) + plx_err(cx, t0->line, + "a type annotation must come after the value: write \"%s%.*s = value /*:: type */\"", + cx->surf->var_sigil == '$' ? "$" : "", t0->len, t0->s); l = plx_local_find(cx, t0->s, t0->len); if (!l) l = plx_local_add(cx, t0->s, t0->len); @@ -2613,7 +2619,137 @@ b64_encode_body(const char *body) return dst; } +/* + * Local-variable types are checked before they are spliced into DECLARE. + * + * plpgsql reads a declaration's type up to the next ':=', DEFAULT, NOT NULL, + * COLLATE or ';'. A type taken verbatim from an annotation could therefore + * carry an initializer: "x #:: int := f()", or "int DEFAULT f()" in a JS + * block-comment annotation, would run f() on entry while the source shows + * only a type (and in JS, only a comment). So a type must be exactly a type name, as + * plpgsql's own parse_datatype accepts it, or name%TYPE / name%ROWTYPE. + */ +static const char * +tt_ws(const char *p) +{ + while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') + p++; + return p; +} + +/* one plain or double-quoted identifier; NULL when there is none */ +static const char * +tt_ident(const char *p) +{ + if (*p == '"') + { + for (p++; *p; p++) + if (*p == '"') + { + if (p[1] != '"') + return p + 1; + p++; + } + return NULL; + } + if (!((*p >= 'a' && *p <= 'z') || (*p >= 'A' && *p <= 'Z') || *p == '_' || + IS_HIGHBIT_SET(*p))) + return NULL; + while ((*p >= 'a' && *p <= 'z') || (*p >= 'A' && *p <= 'Z') || + (*p >= '0' && *p <= '9') || *p == '_' || *p == '$' || IS_HIGHBIT_SET(*p)) + p++; + return p; +} + +/* [schema.]table[.column]%TYPE or %ROWTYPE, optionally followed by [] */ +static bool +tt_is_pct_type(const char *p) +{ + p = tt_ws(p); + for (;;) + { + if (!(p = tt_ident(p))) + return false; + p = tt_ws(p); + if (*p != '.') + break; + p = tt_ws(p + 1); + } + if (*p != '%') + return false; + p = tt_ws(p + 1); + if (pg_strncasecmp(p, "rowtype", 7) == 0) + p += 7; + else if (pg_strncasecmp(p, "type", 4) == 0) + p += 4; + else + return false; + p = tt_ws(p); + while (*p == '[') + { + p = tt_ws(p + 1); + while (*p >= '0' && *p <= '9') + p++; + p = tt_ws(p); + if (*p != ']') + return false; + p = tt_ws(p + 1); + } + return *p == '\0'; +} + +/* the SQL type-name grammar, exactly as plpgsql's parse_datatype reads it */ +static bool +tt_is_sql_type(const char *typ) +{ + MemoryContext oldcxt = CurrentMemoryContext; + volatile bool ok = true; + const char *c; + + /* a comment would hide the declaration's ';' */ + for (c = typ; *c; c++) + if ((c[0] == '-' && c[1] == '-') || (c[0] == '/' && c[1] == '*') || + ((unsigned char) *c < 0x20 && *c != ' ' && *c != '\t' && + *c != '\n' && *c != '\r')) + return false; + + PG_TRY(); + { +#if PG_VERSION_NUM >= 160000 + (void) typeStringToTypeName(typ, NULL); +#else + (void) typeStringToTypeName(typ); +#endif + } + PG_CATCH(); + { + ErrorData *ed; + + MemoryContextSwitchTo(oldcxt); + ed = CopyErrorData(); + if (ed->sqlerrcode != ERRCODE_SYNTAX_ERROR) + PG_RE_THROW(); + FlushErrorState(); + FreeErrorData(ed); + ok = false; + } + PG_END_TRY(); + return ok; +} +static void +plx_check_local_type(const PlxSurface *surf, const PlxLocal2 *l) +{ + if (tt_is_pct_type(l->typ) || tt_is_sql_type(l->typ)) + return; + ereport(ERROR, + (errcode(ERRCODE_SYNTAX_ERROR), + errmsg("%s: invalid type \"%s\" for local variable \"%s\"", + surf->lanname, l->typ, l->name), + errhint("A type annotation must be a type name only, such as integer, " + "numeric(10,2), text[] or tbl%%ROWTYPE. Assign the initial value " + "in the body."))); +} bool plx_has_sentinel(const char *prosrc) @@ -2667,9 +2803,18 @@ plx_transpile(const char *body, const PlxFuncMeta *meta, const PlxSurface *surf, { if (cx.locals) { + const char *checked = NULL; /* last type that passed; runs repeat */ + appendStringInfoString(&asm_, "DECLARE\n"); for (l = cx.locals; l; l = l->next) { + CHECK_FOR_INTERRUPTS(); + if (l->typ && !l->is_record && + (checked == NULL || strcmp(checked, l->typ) != 0)) + { + plx_check_local_type(surf, l); + checked = l->typ; + } if (l->is_record) appendStringInfo(&asm_, " %s RECORD;\n", l->name); else if (!l->typ) diff --git a/test/expected/plx_errors.out b/test/expected/plx_errors.out index fa7c4b0..f63debb 100644 --- a/test/expected/plx_errors.out +++ b/test/expected/plx_errors.out @@ -334,3 +334,77 @@ return 0xFFFFFFFFFFFFFFFFFF; $$; ERROR: plxjs: integer literal out of range: 0xFFFFFFFFFFFFFFFFFF DETAIL: at plxjs source line 0 +-- a type annotation is spliced into DECLARE, so it must be a type name only; +-- anything after the type would become an initializer or a new statement +CREATE FUNCTION e_rb_ann_init() RETURNS int LANGUAGE plxruby AS $$ +x #:: int := (SELECT 42) +return x +$$; +ERROR: plxruby: invalid type "int := (SELECT 42)" for local variable "x" +HINT: A type annotation must be a type name only, such as integer, numeric(10,2), text[] or tbl%ROWTYPE. Assign the initial value in the body. +CREATE FUNCTION e_js_ann_default() RETURNS int LANGUAGE plxjs AS $$ +let x /*:: int DEFAULT 42 */; +return x; +$$; +ERROR: plxjs: invalid type "int DEFAULT 42" for local variable "x" +HINT: A type annotation must be a type name only, such as integer, numeric(10,2), text[] or tbl%ROWTYPE. Assign the initial value in the body. +CREATE FUNCTION e_py_ann_stmt() RETURNS int LANGUAGE plxpython3 AS $$ +x #:: int; BEGIN RAISE NOTICE 'pwn'; END; DECLARE y int +return 1 +$$; +ERROR: plxpython3: invalid type "int; BEGIN RAISE NOTICE 'pwn'; END; DECLARE y int" for local variable "x" +HINT: A type annotation must be a type name only, such as integer, numeric(10,2), text[] or tbl%ROWTYPE. Assign the initial value in the body. +CREATE FUNCTION e_rb_ann_comment() RETURNS text LANGUAGE plxruby AS $$ +x #:: text -- +return x +$$; +ERROR: plxruby: invalid type "text --" for local variable "x" +HINT: A type annotation must be a type name only, such as integer, numeric(10,2), text[] or tbl%ROWTYPE. Assign the initial value in the body. +CREATE FUNCTION e_rb_ann_notnull() RETURNS int LANGUAGE plxruby AS $$ +x #:: int NOT NULL +return 1 +$$; +ERROR: plxruby: invalid type "int NOT NULL" for local variable "x" +HINT: A type annotation must be a type name only, such as integer, numeric(10,2), text[] or tbl%ROWTYPE. Assign the initial value in the body. +CREATE FUNCTION e_rb_ann_rowtype_init() RETURNS int LANGUAGE plxruby AS $$ +x #:: pg_class%ROWTYPE := NULL +return 1 +$$; +ERROR: plxruby: invalid type "pg_class%ROWTYPE := NULL" for local variable "x" +HINT: A type annotation must be a type name only, such as integer, numeric(10,2), text[] or tbl%ROWTYPE. Assign the initial value in the body. +-- a block annotation before the initializer used to drop the initializer +CREATE FUNCTION e_js_ann_before_init() RETURNS int LANGUAGE plxjs AS $$ +let n /*:: bigint */ = 3; +return n; +$$; +ERROR: plxjs: a type annotation must come after the value: write "n = value /*:: type */" +DETAIL: at plxjs source line 2 +CREATE FUNCTION e_php_ann_before_init() RETURNS int LANGUAGE plxphp AS $$ +$n /*:: bigint */ = 3; +return $n; +$$; +ERROR: plxphp: a type annotation must come after the value: write "$n = value /*:: type */" +DETAIL: at plxphp source line 2 +-- every type-name form plpgsql accepts still works +CREATE FUNCTION ok_rb_ann_types() RETURNS text LANGUAGE plxruby AS $$ +a #:: double precision +b #:: timestamp(3) with time zone +c #:: numeric(10, 2) +d #:: character varying(20)[] +e #:: pg_class%ROWTYPE +f #:: pg_catalog.pg_class.relname%TYPE +g #:: "pg_catalog"."int8" +h #:: int[][] +i #:: interval day to second +c = 1.005 +f = "rel" +g = 7 +return "#{c} #{f} #{g}" +$$; +SELECT ok_rb_ann_types(); + ok_rb_ann_types +----------------- + 1.01 rel 7 +(1 row) + +DROP FUNCTION ok_rb_ann_types(); diff --git a/test/sql/plx_errors.sql b/test/sql/plx_errors.sql index 77aee59..0613af5 100644 --- a/test/sql/plx_errors.sql +++ b/test/sql/plx_errors.sql @@ -294,3 +294,59 @@ END $outer$; CREATE FUNCTION e_js_hex_overflow() RETURNS numeric LANGUAGE plxjs AS $$ return 0xFFFFFFFFFFFFFFFFFF; $$; + +-- a type annotation is spliced into DECLARE, so it must be a type name only; +-- anything after the type would become an initializer or a new statement +CREATE FUNCTION e_rb_ann_init() RETURNS int LANGUAGE plxruby AS $$ +x #:: int := (SELECT 42) +return x +$$; +CREATE FUNCTION e_js_ann_default() RETURNS int LANGUAGE plxjs AS $$ +let x /*:: int DEFAULT 42 */; +return x; +$$; +CREATE FUNCTION e_py_ann_stmt() RETURNS int LANGUAGE plxpython3 AS $$ +x #:: int; BEGIN RAISE NOTICE 'pwn'; END; DECLARE y int +return 1 +$$; +CREATE FUNCTION e_rb_ann_comment() RETURNS text LANGUAGE plxruby AS $$ +x #:: text -- +return x +$$; +CREATE FUNCTION e_rb_ann_notnull() RETURNS int LANGUAGE plxruby AS $$ +x #:: int NOT NULL +return 1 +$$; +CREATE FUNCTION e_rb_ann_rowtype_init() RETURNS int LANGUAGE plxruby AS $$ +x #:: pg_class%ROWTYPE := NULL +return 1 +$$; + +-- a block annotation before the initializer used to drop the initializer +CREATE FUNCTION e_js_ann_before_init() RETURNS int LANGUAGE plxjs AS $$ +let n /*:: bigint */ = 3; +return n; +$$; +CREATE FUNCTION e_php_ann_before_init() RETURNS int LANGUAGE plxphp AS $$ +$n /*:: bigint */ = 3; +return $n; +$$; + +-- every type-name form plpgsql accepts still works +CREATE FUNCTION ok_rb_ann_types() RETURNS text LANGUAGE plxruby AS $$ +a #:: double precision +b #:: timestamp(3) with time zone +c #:: numeric(10, 2) +d #:: character varying(20)[] +e #:: pg_class%ROWTYPE +f #:: pg_catalog.pg_class.relname%TYPE +g #:: "pg_catalog"."int8" +h #:: int[][] +i #:: interval day to second +c = 1.005 +f = "rel" +g = 7 +return "#{c} #{f} #{g}" +$$; +SELECT ok_rb_ann_types(); +DROP FUNCTION ok_rb_ann_types();