From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 From: Yuriy Grigoryev Date: Tue, 29 Sep 2026 00:01:00 +0700 Subject: [PATCH v2 2/2] Add a frontend test for malformed WAL image hole geometry. Build in-memory WAL records with compressed and uncompressed full-page images and feed them to DecodeXLogRecord(). The server cannot emit the BUG #19599 shape itself, so a TAP test against a running instance would still need a separate WAL generator. Discussion: https://postgr.es/m/cc0a5886d2a74f99ae58c1647ea5ed8b@localhost.localdomain --- diff --git a/src/test/modules/test_xlogreader/.gitignore b/src/test/modules/test_xlogreader/.gitignore new file mode 100644 index 00000000000..5b90cc8b7af --- /dev/null +++ b/src/test/modules/test_xlogreader/.gitignore @@ -0,0 +1,7 @@ +# Source files copied from src/backend/access/transam/ +/xlogreader.c + +/test_xlogreader + +# Generated by test suite +/tmp_check/ diff --git a/src/test/modules/test_xlogreader/Makefile b/src/test/modules/test_xlogreader/Makefile new file mode 100644 index 00000000000..dc80ef462f6 --- /dev/null +++ b/src/test/modules/test_xlogreader/Makefile @@ -0,0 +1,34 @@ +# src/test/modules/test_xlogreader/Makefile + +PGFILEDESC = "standalone XLogReader decoder tester" +PGAPPICON = win32 + +TAP_TESTS = 1 + +OBJS = \ + $(WIN32RES) \ + test_xlogreader.o \ + xlogreader.o + +EXTRA_CLEAN = test_xlogreader$(X) xlogreader.c + +ifdef USE_PGXS +PG_CONFIG = pg_config +PGXS := $(shell $(PG_CONFIG) --pgxs) +include $(PGXS) +else +subdir = src/test/modules/test_xlogreader +top_builddir = ../../../.. +include $(top_builddir)/src/Makefile.global +include $(top_srcdir)/contrib/contrib-global.mk +endif + +override CPPFLAGS := -DFRONTEND -I$(libpq_srcdir) $(CPPFLAGS) + +all: test_xlogreader$(X) + +xlogreader.c: % : $(top_srcdir)/src/backend/access/transam/% + rm -f $@ && $(LN_S) $< . + +test_xlogreader$(X): $(OBJS) + $(CC) $(CFLAGS) $^ $(PG_LIBS_INTERNAL) $(LDFLAGS) $(LDFLAGS_EX) $(PG_LIBS) $(LIBS) -o $@ diff --git a/src/test/modules/test_xlogreader/meson.build b/src/test/modules/test_xlogreader/meson.build new file mode 100644 index 00000000000..64bddae4a64 --- /dev/null +++ b/src/test/modules/test_xlogreader/meson.build @@ -0,0 +1,38 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +test_xlogreader_sources = files( + 'test_xlogreader.c', +) +test_xlogreader_sources += xlogreader_sources + +if host_system == 'windows' + test_xlogreader_sources += rc_bin_gen.process(win32ver_rc, extra_args: [ + '--NAME', 'test_xlogreader', + '--FILEDESC', 'standalone XLogReader decoder tester', + ]) +endif + +test_xlogreader = executable('test_xlogreader', + test_xlogreader_sources, + dependencies: [frontend_code, lz4, zstd], + c_args: ['-DFRONTEND'], + kwargs: default_bin_args + { + 'install': false, + }, +) + +testprep_targets += test_xlogreader + +tests += { + 'name': 'test_xlogreader', + 'sd': meson.current_source_dir(), + 'bd': meson.current_build_dir(), + 'tap': { + 'tests': [ + 't/001_hole_geometry.pl', + ], + 'deps': [ + test_xlogreader, + ], + }, +} diff --git a/src/test/modules/test_xlogreader/t/001_hole_geometry.pl b/src/test/modules/test_xlogreader/t/001_hole_geometry.pl new file mode 100644 index 00000000000..d4159cd62ec --- /dev/null +++ b/src/test/modules/test_xlogreader/t/001_hole_geometry.pl @@ -0,0 +1,14 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +use strict; +use warnings FATAL => 'all'; + +use PostgreSQL::Test::Utils; +use Test::More; + +my ($stdout, $stderr) = run_command(['test_xlogreader']); + +is($stderr, '', 'no error output'); +like($stdout, qr/All tests passed/, 'malformed hole geometry is rejected'); + +done_testing(); diff --git a/src/test/modules/test_xlogreader/test_xlogreader.c b/src/test/modules/test_xlogreader/test_xlogreader.c new file mode 100644 index 00000000000..d7a71d5545e --- /dev/null +++ b/src/test/modules/test_xlogreader/test_xlogreader.c @@ -0,0 +1,250 @@ +/*------------------------------------------------------------------------- + * + * test_xlogreader.c + * Frontend harness for DecodeXLogRecord() hole-geometry checks + * + * Builds in-memory WAL records with full-page images and feeds them to + * DecodeXLogRecord(). Used to check that malformed hole geometry is + * rejected during decode, before RestoreBlockImage() can touch the page + * buffer. + * + * Copyright (c) 2026, PostgreSQL Global Development Group + * + * IDENTIFICATION + * src/test/modules/test_xlogreader/test_xlogreader.c + * + *------------------------------------------------------------------------- + */ + +/* + * We have to use postgres.h not postgres_fe.h here, because there's so much + * backend-only stuff in the XLOG include files we need. But we need a + * frontend-ish environment otherwise. Hence this ugly hack. + */ +#define FRONTEND 1 +#include "postgres.h" + +#include +#include + +#include "access/transam.h" +#include "access/xlog_internal.h" +#include "access/xlogreader.h" +#include "access/xlogrecord.h" +#include "common/relpath.h" +#include "port/pg_crc32c.h" + +static void +append_bytes(char **dst, const void *src, Size len) +{ + memcpy(*dst, src, len); + *dst += len; +} + +/* + * Build a one-block WAL record containing a full-page image with a hole. + * + * If compressed is true, hole_length is stored on the wire (as in a + * corrupt or hand-built record). Otherwise the decoder derives hole_length as + * BLCKSZ - bimg_len. + * + * A valid CRC is filled in, matching ValidXLogRecord(), even though + * DecodeXLogRecord() itself does not verify the CRC. + */ +static XLogRecord * +build_hole_image_record(char *buf, Size buflen, + uint16 hole_offset, uint16 hole_length, + uint16 bimg_len, bool compressed) +{ + XLogRecord *record; + char *p; + uint8 id; + uint8 fork_flags; + uint8 bimg_info; + uint16 data_len; + RelFileLocator rlocator; + BlockNumber blkno; + pg_crc32c crc; + char image[BLCKSZ]; + + if (bimg_len > BLCKSZ || + buflen < SizeOfXLogRecord + MaxSizeOfXLogRecordBlockHeader + bimg_len) + { + fprintf(stderr, "internal error: cannot build WAL image record\n"); + exit(1); + } + + memset(buf, 0, buflen); + memset(image, 0xA5, sizeof(image)); + + record = (XLogRecord *) buf; + p = buf + SizeOfXLogRecord; + + id = 0; + fork_flags = MAIN_FORKNUM | BKPBLOCK_HAS_IMAGE; + data_len = 0; + append_bytes(&p, &id, sizeof(id)); + append_bytes(&p, &fork_flags, sizeof(fork_flags)); + append_bytes(&p, &data_len, sizeof(data_len)); + + bimg_info = BKPIMAGE_HAS_HOLE | BKPIMAGE_APPLY; + if (compressed) + bimg_info |= BKPIMAGE_COMPRESS_PGLZ; + + append_bytes(&p, &bimg_len, sizeof(bimg_len)); + append_bytes(&p, &hole_offset, sizeof(hole_offset)); + append_bytes(&p, &bimg_info, sizeof(bimg_info)); + if (compressed) + append_bytes(&p, &hole_length, sizeof(hole_length)); + + rlocator.spcOid = 1663; + rlocator.dbOid = 5; + rlocator.relNumber = 16384; + blkno = 0; + append_bytes(&p, &rlocator, sizeof(rlocator)); + append_bytes(&p, &blkno, sizeof(blkno)); + append_bytes(&p, image, bimg_len); + + record->xl_tot_len = p - buf; + record->xl_xid = InvalidTransactionId; + record->xl_prev = InvalidXLogRecPtr; + record->xl_info = 0; + record->xl_rmid = RM_XLOG_ID; + + INIT_CRC32C(crc); + COMP_CRC32C(crc, buf + SizeOfXLogRecord, + record->xl_tot_len - SizeOfXLogRecord); + COMP_CRC32C(crc, buf, offsetof(XLogRecord, xl_crc)); + FIN_CRC32C(crc); + record->xl_crc = crc; + + return record; +} + +static bool +try_decode(XLogReaderState *state, XLogRecord *record, char **errormsg) +{ + DecodedXLogRecord *decoded; + bool ok; + + decoded = (DecodedXLogRecord *) + palloc(DecodeXLogRecordRequiredSpace(record->xl_tot_len)); + decoded->oversized = true; + state->ReadRecPtr = 0x28; + + ok = DecodeXLogRecord(state, decoded, record, state->ReadRecPtr, errormsg); + pfree(decoded); + return ok; +} + +static int +run_case(XLogReaderState *state, const char *name, + uint16 hole_offset, uint16 hole_length, uint16 bimg_len, + bool compressed, bool expect_ok) +{ + char buf[BLCKSZ + 512]; + XLogRecord *record; + char *errormsg = NULL; + bool ok; + + record = build_hole_image_record(buf, sizeof(buf), + hole_offset, hole_length, + bimg_len, compressed); + ok = try_decode(state, record, &errormsg); + + if (ok != expect_ok) + { + fprintf(stderr, + "FAIL: %s: hole_offset=%u hole_length=%u bimg_len=%u compressed=%d: expected %s, got %s%s%s\n", + name, + (unsigned int) hole_offset, + (unsigned int) hole_length, + (unsigned int) bimg_len, + (int) compressed, + expect_ok ? "accept" : "reject", + ok ? "accept" : "reject", + errormsg ? ": " : "", + errormsg ? errormsg : ""); + return 1; + } + + if (!expect_ok && + (errormsg == NULL || strstr(errormsg, "BKPIMAGE_HAS_HOLE set") == NULL)) + { + fprintf(stderr, + "FAIL: %s: rejected, but error did not mention hole geometry: %s\n", + name, + errormsg ? errormsg : "(null)"); + return 1; + } + + printf("ok %s\n", name); + return 0; +} + +int +main(int argc, char *argv[]) +{ + XLogReaderState *state; + int failed = 0; + uint16 near_end; + + (void) argc; + (void) argv; + + state = XLogReaderAllocate(DEFAULT_XLOG_SEG_SIZE, NULL, + XL_ROUTINE(.page_read = NULL, + .segment_open = NULL, + .segment_close = NULL), + NULL); + if (state == NULL) + { + fprintf(stderr, "out of memory while allocating XLogReader\n"); + return 1; + } + + /* Use values near the end of the page, scaled to the build's BLCKSZ. */ + near_end = (uint16) (BLCKSZ - 192); + + /* Valid compressed hole entirely inside the page. */ + failed += run_case(state, "valid compressed hole", + 100, 100, 16, true, true); + + /* Hole ends exactly at BLCKSZ. */ + failed += run_case(state, "valid compressed hole to end of page", + near_end, 192, 16, true, true); + + /* Both fields large, sum past BLCKSZ. */ + failed += run_case(state, "oversized compressed hole", + near_end, near_end, 16, true, false); + + /* Just one byte past the end of the page. */ + failed += run_case(state, "compressed hole one byte past page", + near_end, 193, 16, true, false); + + /* hole_offset itself past the page. */ + if (BLCKSZ < PG_UINT16_MAX) + failed += run_case(state, "hole_offset past page", + (uint16) (BLCKSZ + 1), 1, 16, true, false); + + /* + * Uncompressed images store hole_offset on the wire and derive + * hole_length as BLCKSZ - bimg_len. A large hole_offset still has to + * fit in the remaining image bytes. + */ + failed += run_case(state, "valid uncompressed hole", + 24, 0, 100, false, true); + failed += run_case(state, "uncompressed hole_offset past image", + near_end, 0, 16, false, false); + + XLogReaderFree(state); + + if (failed) + { + fprintf(stderr, "%d test(s) failed\n", failed); + return 1; + } + + printf("All tests passed\n"); + return 0; +} diff --git a/src/test/modules/Makefile b/src/test/modules/Makefile --- a/src/test/modules/Makefile +++ b/src/test/modules/Makefile @@ -43,7 +43,8 @@ SUBDIRS = \ test_shm_mq \ test_slru \ test_tidstore \ test_wait_lsn \ + test_xlogreader \ unsafe_tests \ worker_spi \ xid_wraparound diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build --- a/src/test/modules/meson.build +++ b/src/test/modules/meson.build @@ -42,7 +42,8 @@ subdir('test_rls_hooks') subdir('test_shm_mq') subdir('test_slru') subdir('test_tidstore') subdir('test_wait_lsn') +subdir('test_xlogreader') subdir('typcache') subdir('unsafe_tests') subdir('worker_spi') -- 2.39.2