From be47a5d4bb89d2d095e10932f9adcdc94b7dda6a Mon Sep 17 00:00:00 2001
From: "Chao Li (Evan)" <lic@highgo.com>
Date: Fri, 7 Aug 2026 17:09:55 +0800
Subject: [PATCH v5] Fix detection of truncated compressed backups

pg_verifybackup failed to detect truncated compressed tar backups
for zstd, lz4 and gzip compression.

astreamer_zstd_decompressor checked whether ZSTD_decompressStream()
returned an error.  However, a positive return value at end-of-input
means that the final zstd frame has not completed.  Record the state
and verify at finalization that zstd completed the frame.  Also flush
any pending internally buffered output before performing that check.

astreamer_gzip_decompressor did not check whether inflate() had
reached Z_STREAM_END before finalizing.  Record whether inflate()
completed the gzip stream, and reject finalization if it did not.

astreamer_lz4_decompressor did not check whether LZ4F_decompress()
completed the final frame before finalizing.  Record the status of
decompression and reject finalization unless the frame completed.

This prevents pg_verifybackup from accepting a truncated compressed
tar backup.

Author: Chao Li <lic@highgo.com>
Co-authored-by: Daniel Gustafsson <daniel@yesql.se>
Reviewed-by:
Discussion: https://postgr.es/m/5962B878-C43D-4EBC-9E95-1F945CE5E586@gmail.com
---
 src/bin/pg_verifybackup/t/010_client_untar.pl | 13 +++++
 src/fe_utils/astreamer_gzip.c                 |  8 ++-
 src/fe_utils/astreamer_lz4.c                  | 21 ++++++++
 src/fe_utils/astreamer_zstd.c                 | 53 +++++++++++++++++++
 4 files changed, 94 insertions(+), 1 deletion(-)

diff --git a/src/bin/pg_verifybackup/t/010_client_untar.pl b/src/bin/pg_verifybackup/t/010_client_untar.pl
index db8b96ceb43..438658b52cb 100644
--- a/src/bin/pg_verifybackup/t/010_client_untar.pl
+++ b/src/bin/pg_verifybackup/t/010_client_untar.pl
@@ -138,6 +138,19 @@ for my $tc (@test_configuration)
 			[ 'pg_verifybackup', '--exit-on-error', $backup_path, ],
 			"verify backup, compression $method");
 
+		if ($method ne 'none')
+		{
+			my $flen = -s $backup_path . "/" . $tc->{'backup_archive'};
+			ok( truncate(
+					$backup_path . "/" . $tc->{'backup_archive'},
+					$flen - 1),
+				"file truncated");
+			$primary->command_fails_like(
+				[ 'pg_verifybackup', '--exit-on-error', $backup_path, ],
+				qr/could not decompress data/,
+				"backup is corrupted, compression $method");
+		}
+
 		# Cleanup.
 		rmtree($backup_path);
 	}
diff --git a/src/fe_utils/astreamer_gzip.c b/src/fe_utils/astreamer_gzip.c
index bc3d53076e1..dc884eb3f1e 100644
--- a/src/fe_utils/astreamer_gzip.c
+++ b/src/fe_utils/astreamer_gzip.c
@@ -48,6 +48,7 @@ typedef struct astreamer_gzip_decompressor
 	astreamer	base;
 	z_stream	zstream;
 	size_t		bytes_written;
+	bool		stream_finished;
 } astreamer_gzip_decompressor;
 
 static void astreamer_gzip_writer_content(astreamer *streamer,
@@ -318,7 +319,9 @@ astreamer_gzip_decompressor_content(astreamer *streamer,
 		 */
 		res = inflate(zs, Z_NO_FLUSH);
 
-		if (res != Z_OK && res != Z_STREAM_END && res != Z_BUF_ERROR)
+		if (res == Z_STREAM_END)
+			mystreamer->stream_finished = true;
+		else if (res != Z_OK && res != Z_BUF_ERROR)
 			pg_fatal("could not decompress data: %s",
 					 zs->msg ? zs->msg : "unknown error");
 
@@ -346,6 +349,9 @@ astreamer_gzip_decompressor_finalize(astreamer *streamer)
 
 	mystreamer = (astreamer_gzip_decompressor *) streamer;
 
+	if (!mystreamer->stream_finished)
+		pg_fatal("could not decompress data: compressed stream is incomplete");
+
 	/*
 	 * End of the stream, if there is some pending data in output buffers then
 	 * we must forward it to next streamer.
diff --git a/src/fe_utils/astreamer_lz4.c b/src/fe_utils/astreamer_lz4.c
index 12dfde2c837..dd2bd1d44a5 100644
--- a/src/fe_utils/astreamer_lz4.c
+++ b/src/fe_utils/astreamer_lz4.c
@@ -25,6 +25,14 @@
 #include "fe_utils/astreamer.h"
 
 #ifdef USE_LZ4
+
+typedef enum
+{
+	STREAM_NEW,
+	FRAME_HAS_DATA,
+	FRAME_FINISHED,
+}			pg_stream_state;
+
 typedef struct astreamer_lz4_frame
 {
 	astreamer	base;
@@ -35,6 +43,7 @@ typedef struct astreamer_lz4_frame
 
 	size_t		bytes_written;
 	bool		header_written;
+	pg_stream_state state;
 } astreamer_lz4_frame;
 
 static void astreamer_lz4_compressor_content(astreamer *streamer,
@@ -297,6 +306,7 @@ astreamer_lz4_decompressor_new(astreamer *next)
 		pg_fatal("could not initialize compression library: %s",
 				 LZ4F_getErrorName(ctxError));
 
+	streamer->state = STREAM_NEW;
 	return &streamer->base;
 #else
 	pg_fatal("this build does not support compression with %s", "LZ4");
@@ -358,6 +368,12 @@ astreamer_lz4_decompressor_content(astreamer *streamer,
 			pg_fatal("could not decompress data: %s",
 					 LZ4F_getErrorName(ret));
 
+		/* The frame is only done when LZ4F_decompress returns 0 */
+		if (ret)
+			mystreamer->state = FRAME_HAS_DATA;
+		else
+			mystreamer->state = FRAME_FINISHED;
+
 		/* Update input buffer based on number of bytes consumed */
 		avail_in -= read_size;
 		next_in += read_size;
@@ -395,6 +411,11 @@ astreamer_lz4_decompressor_finalize(astreamer *streamer)
 
 	mystreamer = (astreamer_lz4_frame *) streamer;
 
+	if (mystreamer->state == FRAME_HAS_DATA)
+		pg_fatal("could not decompress data: compressed stream is incomplete");
+	else if (unlikely(mystreamer->state == STREAM_NEW))
+		pg_fatal("could not decompress data: compressed stream is empty");
+
 	/*
 	 * End of the stream, if there is some pending data in output buffers then
 	 * we must forward it to next streamer.
diff --git a/src/fe_utils/astreamer_zstd.c b/src/fe_utils/astreamer_zstd.c
index 98e8a700efe..065d1f8d251 100644
--- a/src/fe_utils/astreamer_zstd.c
+++ b/src/fe_utils/astreamer_zstd.c
@@ -26,6 +26,13 @@
 
 #ifdef USE_ZSTD
 
+typedef enum
+{
+	STREAM_NEW,
+	FRAME_HAS_DATA,
+	FRAME_FINISHED,
+}			pg_stream_state;
+
 typedef struct astreamer_zstd_frame
 {
 	astreamer	base;
@@ -33,6 +40,7 @@ typedef struct astreamer_zstd_frame
 	ZSTD_CCtx  *cctx;
 	ZSTD_DCtx  *dctx;
 	ZSTD_outBuffer zstd_outBuf;
+	pg_stream_state state;
 } astreamer_zstd_frame;
 
 static void astreamer_zstd_compressor_content(astreamer *streamer,
@@ -280,6 +288,7 @@ astreamer_zstd_decompressor_new(astreamer *next)
 	streamer->zstd_outBuf.size = streamer->base.bbs_buffer.maxlen;
 	streamer->zstd_outBuf.pos = 0;
 
+	streamer->state = STREAM_NEW;
 	return &streamer->base;
 #else
 	pg_fatal("this build does not support compression with %s", "ZSTD");
@@ -329,6 +338,12 @@ astreamer_zstd_decompressor_content(astreamer *streamer,
 		if (ZSTD_isError(ret))
 			pg_fatal("could not decompress data: %s",
 					 ZSTD_getErrorName(ret));
+
+		/* The frame is only done when ZSTD_decompressStream returns 0 */
+		if (ret)
+			mystreamer->state = FRAME_HAS_DATA;
+		else
+			mystreamer->state = FRAME_FINISHED;
 	}
 }
 
@@ -340,6 +355,44 @@ astreamer_zstd_decompressor_finalize(astreamer *streamer)
 {
 	astreamer_zstd_frame *mystreamer = (astreamer_zstd_frame *) streamer;
 
+	/*
+	 * A full output buffer with a positive return value might leave data in
+	 * zstd's internal buffers. Call the decompressor with empty input until
+	 * it has flushed that data.
+	 */
+	while (mystreamer->state == FRAME_HAS_DATA &&
+		   mystreamer->zstd_outBuf.pos == mystreamer->zstd_outBuf.size)
+	{
+		ZSTD_inBuffer empty = {NULL, 0, 0};
+		size_t		ret;
+
+		astreamer_content(mystreamer->base.bbs_next, NULL,
+						  mystreamer->zstd_outBuf.dst,
+						  mystreamer->zstd_outBuf.pos, ASTREAMER_UNKNOWN);
+
+		mystreamer->zstd_outBuf.dst = mystreamer->base.bbs_buffer.data;
+		mystreamer->zstd_outBuf.size = mystreamer->base.bbs_buffer.maxlen;
+		mystreamer->zstd_outBuf.pos = 0;
+
+		ret = ZSTD_decompressStream(mystreamer->dctx,
+									&mystreamer->zstd_outBuf, &empty);
+
+		if (ZSTD_isError(ret))
+			pg_fatal("could not decompress data: %s",
+					 ZSTD_getErrorName(ret));
+
+		/* The frame is only done when ZSTD_decompressStream returns 0 */
+		if (ret)
+			mystreamer->state = FRAME_HAS_DATA;
+		else
+			mystreamer->state = FRAME_FINISHED;
+	}
+
+	if (unlikely(mystreamer->state == STREAM_NEW))
+		pg_fatal("could not decompress data: compressed stream is empty");
+	else if (mystreamer->state != FRAME_FINISHED)
+		pg_fatal("could not decompress data: compressed stream is incomplete");
+
 	/*
 	 * End of the stream, if there is some pending data in output buffers then
 	 * we must forward it to next streamer.
-- 
2.50.1 (Apple Git-155)

