Hi Yuriy,

Thanks for v2. All my comments are addressed, and I'm marking the
entry Ready for Committer.

Testing (macOS arm64, meson debug build with assertions, master at
9510a826e4 plus v2):

- Both patches apply cleanly with git am, build without warnings, and
the regression tests pass.

- test_xlogreader passes. Without 0001 it fails the four malformed
cases and still accepts the three valid ones, so it does catch the
bug.

- Crash recovery with wal_compression = off, pglz, lz4 and zstd, each
replaying about 1,000 full-page images with holes, plus a pglz run
with wal_consistency_checking = all (about 3,500 images). The data
matched before and after the crash, amcheck found nothing, and the
new Asserts never fired.

- Records with the hole moved past the page, one uncompressed and one
pglz-compressed: pg_waldump reports the BKPIMAGE_HAS_HOLE error,
pg_waldump --save-fullpage exits with an error instead of crashing,
and crash recovery stops at the bad record.

One small suggestion for 0002: the uncompressed cases don't test the
boundary. hole_offset == bimg_len (accepted, the hole ends exactly at
BLCKSZ) and hole_offset == bimg_len + 1 (rejected) would match what
the compressed cases already do.

The same runs show the LSN problem again: for the corrupted record at
0/01E582A0, pg_waldump reported 0/01E58218, the previous record, and
recovery reported 0/01E581B8. I'll send that patch in a separate
thread.

The crafting script is attached. It finds the first full-page image
with a hole after a given LSN in a copy of pg_wal, moves the hole past
the end of the page, and recomputes the record CRC.

Regards,
Rahul Yadav
#!/usr/bin/env python3
"""Corrupt the hole of one full-page image in a copy of a WAL directory.

Usage: corrupt_fpi.py <pg_wal copy> <pg_waldump> <start lsn>
Finds the first record whose block 0 carries an FPI with a hole, moves the
hole so it no longer fits in BLCKSZ, recomputes the record CRC, and prints
the record's LSN.  The WAL is modified in place (use a copy).
"""
import re, struct, subprocess, sys

BLCKSZ, XLOG_BLCKSZ, SEGSZ = 8192, 8192, 16 * 1024 * 1024
SHORT_PHD, LONG_PHD, SIZEOF_XLOGRECORD = 24, 40, 24
BKPBLOCK_HAS_IMAGE, BKPIMAGE_HAS_HOLE, BKPIMAGE_COMPRESSED = 0x10, 0x01, 0x04 | 
0x08 | 0x10

# CRC-32C (Castagnoli), reflected, as PostgreSQL's pg_crc32c
TABLE = []
for i in range(256):
    c = i
    for _ in range(8):
        c = (c >> 1) ^ 0x82F63B78 if c & 1 else c >> 1
    TABLE.append(c)

def crc_update(crc, data):
    for b in data:
        crc = TABLE[(crc ^ b) & 0xFF] ^ (crc >> 8)
    return crc

def record_crc(rec):
    # same order as XLogRecordAssemble/ValidXLogRecord: payload first, then 
header up to xl_crc
    crc = crc_update(0xFFFFFFFF, rec[SIZEOF_XLOGRECORD:])
    crc = crc_update(crc, rec[:20])
    return crc ^ 0xFFFFFFFF

def segfile(waldir, lsn, tli=1):
    segno = lsn // SEGSZ
    return f"{waldir}/{tli:08X}{segno // 256:08X}{segno % 256:08X}"

def physical_offsets(lsn, length):
    """Map logical record bytes to physical offsets in the segment, skipping 
page headers."""
    off, out = lsn % SEGSZ, []
    while len(out) < length:
        if off % XLOG_BLCKSZ == 0:
            off += LONG_PHD if off == 0 else SHORT_PHD
        out.append(off)
        off += 1
    if out[-1] >= SEGSZ:
        raise ValueError("record crosses a segment boundary")
    return out

waldir, waldump, start = sys.argv[1:4]
dump = subprocess.run([waldump, "-p", waldir, "-s", start, "-b"], 
capture_output=True, text=True).stdout
records = re.split(r"\n(?=rmgr:)", dump)
for r in records:
    m = re.search(r"len \(rec/tot\):\s*\d+/\s*(\d+).*?lsn: 
([0-9A-F]+)/([0-9A-F]+)", r)
    b = re.search(r"blkref #0:.*?FPW.*?hole: offset: (\d+), length: (\d+)", r)
    if not (m and b):
        continue
    tot, lsn = int(m.group(1)), (int(m.group(2), 16) << 32) | int(m.group(3), 
16)
    if lsn % XLOG_BLCKSZ > XLOG_BLCKSZ - 64:          # keep the headers we 
edit on one page
        continue
    try:
        offs = physical_offsets(lsn, tot)
    except ValueError:
        continue
    path = segfile(waldir, lsn)
    data = bytearray(open(path, "rb").read())
    rec = bytearray(data[o] for o in offs)
    stored = struct.unpack_from("<I", rec, 20)[0]
    assert record_crc(rec) == stored, "CRC implementation/extraction mismatch"
    block_id, fork_flags = rec[24], rec[25]
    assert block_id == 0 and fork_flags & BKPBLOCK_HAS_IMAGE
    bimg_len, hole_offset, bimg_info = struct.unpack_from("<HHB", rec, 28)
    assert bimg_info & BKPIMAGE_HAS_HOLE
    compressed = bool(bimg_info & BKPIMAGE_COMPRESSED)
    hole_length = struct.unpack_from("<H", rec, 33)[0] if compressed else 
BLCKSZ - bimg_len
    # push the hole past the end of the page, leaving everything else (incl. 
compressed data) intact
    new_offset = BLCKSZ - hole_length + 1000
    struct.pack_into("<H", rec, 30, new_offset)
    struct.pack_into("<I", rec, 20, record_crc(rec))
    for i in range(40):                            # write back the 
(single-page) header region
        data[offs[i]] = rec[i]
    open(path, "wb").write(data)
    print(f"lsn={lsn >> 32:X}/{lsn & 0xFFFFFFFF:08X} compressed={compressed} 
bimg_len={bimg_len} "
          f"hole_offset {hole_offset} -> {new_offset}, 
hole_length={hole_length} "
          f"(offset+length={new_offset + hole_length} > BLCKSZ) crc 
{stored:08x} -> {struct.unpack_from('<I', rec, 20)[0]:08x}")
    break
else:
    sys.exit("no suitable FPI record found")

Reply via email to