catalog/aclchk's with_acl_datum reads a stored aclitem[] in place and raises on any varlena that is not plain, so once the toaster stores a catalog ACL compressed, every subsequent privilege check, GRANT and REVOKE against that object fails.
Version: pgrust 0.2 (PostgreSQL 18.3 compatible), commit d13d781fb9 (a spike branch on top of the public 0.2 tree; the affected code is unchanged from upstream).
Targets: reproduced on wasm32-wasip1-threads (64-bit Datum, memory store) and on a native x86_64-unknown-linux-gnu --host-pipes build of the same commit. Not target-specific.
Reproduction:
DO $$
BEGIN
FOR i IN 1..113 LOOP
EXECUTE format('CREATE ROLE pgrust_repro_g%s', i);
END LOOP;
END;
$$;
CREATE FUNCTION public.pgrust_repro_f() RETURNS int LANGUAGE sql AS 'SELECT 1';
DO $$
BEGIN
EXECUTE 'GRANT EXECUTE ON FUNCTION public.pgrust_repro_f() TO '
|| (SELECT string_agg(format('pgrust_repro_g%s', i), ', ') FROM generate_series(1, 113) AS i);
END;
$$;
SELECT has_function_privilege('pgrust_repro_g1', 'public.pgrust_repro_f()', 'EXECUTE') AS may_execute;
Actual:
ERROR: aclchk: compressed/external ACL varlena — detoast gap
thread 'pg:backend:1031' panicked at crates/backend/catalog/aclchk/src/lib.rs:92:13:
aclchk: compressed/external ACL varlena — detoast gap
Expected (PostgreSQL 18.3, and PGlite 0.5.5 which is PostgreSQL 18.3 compiled to wasm): one row, t.
What is required to hit it
A proacl the toaster did not leave plain, and then any aclchk read of it. 113 grantees is the measured boundary on an 8 kB-page build: 115 aclitems (the roles plus the owner plus PUBLIC) = 1864 bytes, which pushes the pg_proc tuple past TOAST_TUPLE_THRESHOLD and is stored compressed at 402 bytes. 112 grantees (114 items, 1848 bytes) still fits plain and answers normally. PGlite reports the same pg_column_size(proacl) at every count, so this is not a divergence in the toaster — only in the reader.
Grantee count is just the shortest way to write the condition. A four-item ACL in a pg_proc tuple with a ~4.5 kB prosrc is compressed the same way: there, a first REVOKE ALL … FROM PUBLIC succeeds (the ACL is still plain, 85 bytes) and rewrites proacl as a 38-byte compressed value, and the next ACL read fails.
Readers affected: has_function_privilege and REVOKE both fail. aclexplode(p.proacl) is fine — it goes through utils/adt/acl, which detoasts — so the gap is specifically the in-place reader in aclchk.
Where
crates/backend/catalog/aclchk/src/lib.rs:80 with_acl_datum; the panic! is at :92:
if varatt::varatt_is_1b_e(p) || (!varatt::varatt_is_1b(p) && !varatt::varatt_is_4b_u(p)) {
// pg_class/pg_attribute have no toast tables; only inline
// compression can appear here.
panic!("aclchk: compressed/external ACL varlena — detoast gap");
}
The comment names the assumption: pg_proc does have a toast table (pg_toast_1255), and inline compression alone reaches the panic! in any case. C's DatumGetAclP (utils/acl.h) is PG_DETOAST_DATUM, which handles both before any item is read.
crates/backend/catalog/aclchk/src/grant.rs:1862 carries a guard of the same shape over pg_parameter_acl.parname (a text field, not an ACL). Not reproduced here, but it may be the same omission.
Impact seen in the wild. A deny-by-default install that revokes PUBLIC and then converges the remaining grantees — two ACL reads on the same function, in one migration — fails on the second.
catalog/aclchk'swith_acl_datumreads a storedaclitem[]in place and raises on any varlena that is not plain, so once the toaster stores a catalog ACL compressed, every subsequent privilege check,GRANTandREVOKEagainst that object fails.Version:
pgrust 0.2 (PostgreSQL 18.3 compatible), commitd13d781fb9(a spike branch on top of the public 0.2 tree; the affected code is unchanged from upstream).Targets: reproduced on
wasm32-wasip1-threads(64-bitDatum, memory store) and on a nativex86_64-unknown-linux-gnu--host-pipesbuild of the same commit. Not target-specific.Reproduction:
Actual:
Expected (PostgreSQL 18.3, and PGlite 0.5.5 which is PostgreSQL 18.3 compiled to wasm): one row,
t.What is required to hit it
A
proaclthe toaster did not leave plain, and then anyaclchkread of it. 113 grantees is the measured boundary on an 8 kB-page build: 115 aclitems (the roles plus the owner plus PUBLIC) = 1864 bytes, which pushes thepg_proctuple pastTOAST_TUPLE_THRESHOLDand is stored compressed at 402 bytes. 112 grantees (114 items, 1848 bytes) still fits plain and answers normally. PGlite reports the samepg_column_size(proacl)at every count, so this is not a divergence in the toaster — only in the reader.Grantee count is just the shortest way to write the condition. A four-item ACL in a
pg_proctuple with a ~4.5 kBprosrcis compressed the same way: there, a firstREVOKE ALL … FROM PUBLICsucceeds (the ACL is still plain, 85 bytes) and rewritesproaclas a 38-byte compressed value, and the next ACL read fails.Readers affected:
has_function_privilegeandREVOKEboth fail.aclexplode(p.proacl)is fine — it goes throughutils/adt/acl, which detoasts — so the gap is specifically the in-place reader inaclchk.Where
crates/backend/catalog/aclchk/src/lib.rs:80with_acl_datum; thepanic!is at:92:The comment names the assumption:
pg_procdoes have a toast table (pg_toast_1255), and inline compression alone reaches thepanic!in any case. C'sDatumGetAclP(utils/acl.h) isPG_DETOAST_DATUM, which handles both before any item is read.crates/backend/catalog/aclchk/src/grant.rs:1862carries a guard of the same shape overpg_parameter_acl.parname(atextfield, not an ACL). Not reproduced here, but it may be the same omission.Impact seen in the wild. A deny-by-default install that revokes PUBLIC and then converges the remaining grantees — two ACL reads on the same function, in one migration — fails on the second.