From 2cf3b0eaefa275d6ecbab7891f556712d29d0d49 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Tue, 31 Mar 2026 16:41:38 +0200 Subject: [PATCH] Fix stream filter seeking resetting write filter state on read seeks --- ext/bz2/tests/bz2_filter_seek_compress.phpt | 42 +++++++------- ext/standard/tests/filters/chunked_002.phpt | 58 ++++++++++++++++++++ ext/zlib/tests/zlib_filter_seek_deflate.phpt | 43 +++++++-------- main/streams/streams.c | 11 ++-- 4 files changed, 102 insertions(+), 52 deletions(-) create mode 100644 ext/standard/tests/filters/chunked_002.phpt diff --git a/ext/bz2/tests/bz2_filter_seek_compress.phpt b/ext/bz2/tests/bz2_filter_seek_compress.phpt index 0656b244484d9..557021c9fd0dc 100644 --- a/ext/bz2/tests/bz2_filter_seek_compress.phpt +++ b/ext/bz2/tests/bz2_filter_seek_compress.phpt @@ -1,55 +1,51 @@ --TEST-- -bzip2.compress filter with seek to start +bzip2.compress write filter is not reset on seek --EXTENSIONS-- bz2 --FILE-- $size1 ? "YES" : "NO") . "\n"; - +/* Seek to middle also succeeds */ $result = fseek($fp, 50, SEEK_SET); echo "Seek to middle: " . ($result === 0 ? "SUCCESS" : "FAILURE") . "\n"; fclose($fp); +/* Verify the compressed output is still valid */ $fp = fopen($file, 'r'); stream_filter_append($fp, 'bzip2.decompress', STREAM_FILTER_READ); $content = stream_get_contents($fp); fclose($fp); -echo "Decompressed content matches text2: " . ($content === $text2 ? "YES" : "NO") . "\n"; +echo "Decompressed content matches: " . ($content === $text ? "YES" : "NO") . "\n"; ?> --CLEAN-- --EXPECTF-- -Size after first write: 40 +Size after write: %d Seek to start: SUCCESS -Size after second write: 98 -Second write is larger: YES - -Warning: fseek(): Stream filter bzip2.compress is seekable only to start position in %s on line %d -Seek to middle: FAILURE -Decompressed content matches text2: YES +Seek to middle: SUCCESS +Decompressed content matches: YES diff --git a/ext/standard/tests/filters/chunked_002.phpt b/ext/standard/tests/filters/chunked_002.phpt new file mode 100644 index 0000000000000..7114e278e3bd5 --- /dev/null +++ b/ext/standard/tests/filters/chunked_002.phpt @@ -0,0 +1,58 @@ +--TEST-- +Dechunk write filter state must survive stream seek +--FILE-- + +--EXPECT-- +string(5) "Hello" +string(12) "Hello, World" +string(12) "Hello, World" +string(5) "Hello" +int(5) diff --git a/ext/zlib/tests/zlib_filter_seek_deflate.phpt b/ext/zlib/tests/zlib_filter_seek_deflate.phpt index 6acee8e4e8c81..743f1be566fd0 100644 --- a/ext/zlib/tests/zlib_filter_seek_deflate.phpt +++ b/ext/zlib/tests/zlib_filter_seek_deflate.phpt @@ -1,55 +1,52 @@ --TEST-- -zlib.deflate filter with seek to start +zlib.deflate write filter is not reset on seek --EXTENSIONS-- zlib --FILE-- $size1 ? "YES" : "NO") . "\n"; - +/* Seek to middle also succeeds */ $result = fseek($fp, 50, SEEK_SET); echo "Seek to middle: " . ($result === 0 ? "SUCCESS" : "FAILURE") . "\n"; fclose($fp); +/* Verify the compressed output is still valid */ $fp = fopen($file, 'r'); stream_filter_append($fp, 'zlib.inflate', STREAM_FILTER_READ); $content = stream_get_contents($fp); fclose($fp); -echo "Decompressed content matches text2: " . ($content === $text2 ? "YES" : "NO") . "\n"; +echo "Decompressed content matches: " . ($content === $text ? "YES" : "NO") . "\n"; ?> --CLEAN-- --EXPECTF-- -Size after first write: %d +Size after write: %d Seek to start: SUCCESS -Size after second write: %d -Second write is larger: YES - -Warning: fseek(): Stream filter zlib.deflate is seekable only to start position in %s on line %d -Seek to middle: FAILURE -Decompressed content matches text2: YES +Seek to middle: SUCCESS +Decompressed content matches: YES diff --git a/main/streams/streams.c b/main/streams/streams.c index 26f147632cef7..ceccf4ea9ccd5 100644 --- a/main/streams/streams.c +++ b/main/streams/streams.c @@ -1396,9 +1396,11 @@ static zend_result php_stream_filters_seek(php_stream *stream, php_stream_filter static zend_result php_stream_filters_seek_all(php_stream *stream, bool is_start_seeking, zend_off_t offset, int whence) { - if (php_stream_filters_seek(stream, stream->writefilters.head, is_start_seeking, offset, whence) == FAILURE) { - return FAILURE; - } + /* Write filters are not reset on seek. Their state tracks the + * transformation of data written through them and is independent of the + * stream's read/write position. Resetting them would break stateful write + * filters (e.g. dechunk on php://temp) whose stream is seeked only to + * re-read already-filtered output via stream_get_contents(). */ if (php_stream_filters_seek(stream, stream->readfilters.head, is_start_seeking, offset, whence) == FAILURE) { return FAILURE; } @@ -1424,9 +1426,6 @@ PHPAPI int _php_stream_seek(php_stream *stream, zend_off_t offset, int whence) if (stream->writefilters.head) { _php_stream_flush(stream, 0); - if (!php_stream_are_filters_seekable(stream->writefilters.head, is_start_seeking)) { - return -1; - } } if (stream->readfilters.head && !php_stream_are_filters_seekable(stream->readfilters.head, is_start_seeking)) { return -1;