Commit b114db0ddc2 for php.net
commit b114db0ddc29d799918f05fad73daf79863d3011
Author: Jakub Zelenka <bukka@php.net>
Date: Mon May 11 20:12:08 2026 +0200
streams: split filter seekability into read and write fields (#21878)
The single seekable field caused write-chain seeks to reset filter
state after the bug #49874 fix, breaking dechunk on php://temp (used
by Symfony HttpClient).
Split into read_seekable and write_seekable. Write defaults to ALWAYS
for stateless and non-buffering filters. Buffer-holding filters
(zlib, bz2, convert.*) accept a write_seek_mode parameter:
"preserve" (default), "reset", or "strict". Invalid values throw
ValueError.
php_user_filter::seek gains a third int $chain argument; the ops
seek signature is unchanged.
diff --git a/ext/bz2/bz2_filter.c b/ext/bz2/bz2_filter.c
index e1b24f6319f..09c49fa7668 100644
--- a/ext/bz2/bz2_filter.c
+++ b/ext/bz2/bz2_filter.c
@@ -381,9 +381,14 @@ static const php_stream_filter_ops php_bz2_compress_ops = {
static php_stream_filter *php_bz2_filter_create(const char *filtername, zval *filterparams, bool persistent)
{
const php_stream_filter_ops *fops = NULL;
+ php_stream_filter_seekable_t write_seekable;
php_bz2_filter_data *data;
int status = BZ_OK;
+ if (php_stream_filter_parse_write_seek_mode(filterparams, &write_seekable) == FAILURE) {
+ return NULL;
+ }
+
/* Create this filter */
data = pecalloc(1, sizeof(php_bz2_filter_data), persistent);
@@ -476,7 +481,7 @@ static php_stream_filter *php_bz2_filter_create(const char *filtername, zval *fi
return NULL;
}
- return php_stream_filter_alloc(fops, data, persistent, PSFS_SEEKABLE_START);
+ return php_stream_filter_alloc(fops, data, persistent, PSFS_SEEKABLE_START, write_seekable);
}
const php_stream_filter_factory php_bz2_filter_factory = {
diff --git a/ext/bz2/tests/bz2_filter_seek_compress.phpt b/ext/bz2/tests/bz2_filter_seek_compress.phpt
index 0656b244484..557021c9fd0 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--
<?php
+/* Write filters are not reset on stream seek; seeking only affects the
+ * stream's read/write position, not the filter pipeline state. */
+
$file = __DIR__ . '/bz2_filter_seek_compress.bz2';
-$text1 = 'Short text.';
-$text2 = 'This is a much longer text that will completely overwrite the previous compressed data in the file.';
+$text = 'Hello, World!';
$fp = fopen($file, 'w+');
-stream_filter_append($fp, 'bzip2.compress', STREAM_FILTER_WRITE);
+$filter = stream_filter_append($fp, 'bzip2.compress', STREAM_FILTER_WRITE);
+
+fwrite($fp, $text);
-fwrite($fp, $text1);
-fflush($fp);
+/* Remove the filter to finalize compression cleanly before seeking */
+stream_filter_remove($filter);
-$size1 = ftell($fp);
-echo "Size after first write: $size1\n";
+$size = ftell($fp);
+echo "Size after write: $size\n";
+/* Seek to start succeeds; write filters no longer block seeking */
$result = fseek($fp, 0, SEEK_SET);
echo "Seek to start: " . ($result === 0 ? "SUCCESS" : "FAILURE") . "\n";
-fwrite($fp, $text2);
-fflush($fp);
-
-$size2 = ftell($fp);
-echo "Size after second write: $size2\n";
-echo "Second write is larger: " . ($size2 > $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--
<?php
@unlink(__DIR__ . '/bz2_filter_seek_compress.bz2');
?>
--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/bz2/tests/bz2_filter_write_seek_modes.phpt b/ext/bz2/tests/bz2_filter_write_seek_modes.phpt
new file mode 100644
index 00000000000..b3b4fa39eb1
--- /dev/null
+++ b/ext/bz2/tests/bz2_filter_write_seek_modes.phpt
@@ -0,0 +1,54 @@
+--TEST--
+bzip2.compress write filter: write_seek_mode parameter
+--EXTENSIONS--
+bz2
+--FILE--
+<?php
+$file = __DIR__ . '/bz2_filter_write_seek_modes.bz2';
+
+$text1 = 'First message that will be discarded.';
+$text2 = 'Second message that replaces the first.';
+
+/* "reset" */
+$fp = fopen($file, 'w+');
+stream_filter_append($fp, 'bzip2.compress', STREAM_FILTER_WRITE,
+ ['write_seek_mode' => 'reset']);
+fwrite($fp, $text1);
+ftruncate($fp, 0);
+var_dump(fseek($fp, 0, SEEK_SET) === 0);
+fwrite($fp, $text2);
+fclose($fp);
+
+$fp = fopen($file, 'r');
+stream_filter_append($fp, 'bzip2.decompress', STREAM_FILTER_READ);
+$decoded = stream_get_contents($fp);
+fclose($fp);
+var_dump($decoded === $text2);
+
+/* "strict" */
+$fp = fopen($file, 'w+');
+stream_filter_append($fp, 'bzip2.compress', STREAM_FILTER_WRITE,
+ ['write_seek_mode' => 'strict']);
+fwrite($fp, $text1);
+var_dump(@fseek($fp, 0, SEEK_SET) === -1);
+fclose($fp);
+
+/* Invalid mode: ValueError */
+$fp = fopen($file, 'w+');
+stream_filter_append($fp, 'bzip2.compress', STREAM_FILTER_WRITE,
+ ['write_seek_mode' => 'nope']);
+fclose($fp);
+
+?>
+--CLEAN--
+<?php
+@unlink(__DIR__ . '/bz2_filter_write_seek_modes.bz2');
+?>
+--EXPECTF--
+bool(true)
+bool(true)
+bool(true)
+
+Warning: stream_filter_append(): "write_seek_mode" filter parameter must be one of "preserve", "reset", or "strict" in %s
+
+Warning: stream_filter_append(): Unable to create or locate filter "bzip2.compress" in %s
diff --git a/ext/iconv/iconv.c b/ext/iconv/iconv.c
index 5dfc5d9a190..d9b54388a8f 100644
--- a/ext/iconv/iconv.c
+++ b/ext/iconv/iconv.c
@@ -2633,9 +2633,14 @@ static const php_stream_filter_ops php_iconv_stream_filter_ops = {
static php_stream_filter *php_iconv_stream_filter_factory_create(const char *name, zval *params, bool persistent)
{
php_iconv_stream_filter *inst;
+ php_stream_filter_seekable_t write_seekable;
const char *from_charset = NULL, *to_charset = NULL;
size_t from_charset_len, to_charset_len;
+ if (php_stream_filter_parse_write_seek_mode(params, &write_seekable) == FAILURE) {
+ return NULL;
+ }
+
if ((from_charset = strchr(name, '.')) == NULL) {
return NULL;
}
@@ -2663,7 +2668,7 @@ static php_stream_filter *php_iconv_stream_filter_factory_create(const char *nam
}
return php_stream_filter_alloc(&php_iconv_stream_filter_ops, inst, persistent,
- PSFS_SEEKABLE_START);
+ PSFS_SEEKABLE_START, write_seekable);
}
/* }}} */
diff --git a/ext/standard/filters.c b/ext/standard/filters.c
index 5ee7e93c40c..d26429704e2 100644
--- a/ext/standard/filters.c
+++ b/ext/standard/filters.c
@@ -63,7 +63,8 @@ static const php_stream_filter_ops strfilter_rot13_ops = {
static php_stream_filter *strfilter_rot13_create(const char *filtername, zval *filterparams, bool persistent)
{
- return php_stream_filter_alloc(&strfilter_rot13_ops, NULL, persistent, PSFS_SEEKABLE_ALWAYS);
+ return php_stream_filter_alloc(&strfilter_rot13_ops, NULL, persistent,
+ PSFS_SEEKABLE_ALWAYS, PSFS_SEEKABLE_ALWAYS);
}
static const php_stream_filter_factory strfilter_rot13_factory = {
@@ -147,12 +148,14 @@ static const php_stream_filter_ops strfilter_tolower_ops = {
static php_stream_filter *strfilter_toupper_create(const char *filtername, zval *filterparams, bool persistent)
{
- return php_stream_filter_alloc(&strfilter_toupper_ops, NULL, persistent, PSFS_SEEKABLE_ALWAYS);
+ return php_stream_filter_alloc(&strfilter_toupper_ops, NULL, persistent,
+ PSFS_SEEKABLE_ALWAYS, PSFS_SEEKABLE_ALWAYS);
}
static php_stream_filter *strfilter_tolower_create(const char *filtername, zval *filterparams, bool persistent)
{
- return php_stream_filter_alloc(&strfilter_tolower_ops, NULL, persistent, PSFS_SEEKABLE_ALWAYS);
+ return php_stream_filter_alloc(&strfilter_tolower_ops, NULL, persistent,
+ PSFS_SEEKABLE_ALWAYS, PSFS_SEEKABLE_ALWAYS);
}
static const php_stream_filter_factory strfilter_toupper_factory = {
@@ -1634,7 +1637,7 @@ static const php_stream_filter_ops strfilter_convert_ops = {
static php_stream_filter *strfilter_convert_create(const char *filtername, zval *filterparams, bool persistent)
{
php_convert_filter *inst;
-
+ php_stream_filter_seekable_t write_seekable;
const char *dot;
int conv_mode = 0;
@@ -1648,6 +1651,10 @@ static php_stream_filter *strfilter_convert_create(const char *filtername, zval
}
++dot;
+ if (php_stream_filter_parse_write_seek_mode(filterparams, &write_seekable) == FAILURE) {
+ return NULL;
+ }
+
inst = pemalloc(sizeof(php_convert_filter), persistent);
if (strcasecmp(dot, "base64-encode") == 0) {
@@ -1667,7 +1674,7 @@ static php_stream_filter *strfilter_convert_create(const char *filtername, zval
return NULL;
}
- return php_stream_filter_alloc(&strfilter_convert_ops, inst, persistent, PSFS_SEEKABLE_START);
+ return php_stream_filter_alloc(&strfilter_convert_ops, inst, persistent, PSFS_SEEKABLE_START, write_seekable);
}
static const php_stream_filter_factory strfilter_convert_factory = {
@@ -1761,7 +1768,7 @@ static php_stream_filter *consumed_filter_create(const char *filtername, zval *f
data->offset = ~0;
fops = &consumed_filter_ops;
- return php_stream_filter_alloc(fops, data, persistent, PSFS_SEEKABLE_START);
+ return php_stream_filter_alloc(fops, data, persistent, PSFS_SEEKABLE_START, PSFS_SEEKABLE_ALWAYS);
}
static const php_stream_filter_factory consumed_filter_factory = {
@@ -1992,7 +1999,7 @@ static php_stream_filter *chunked_filter_create(const char *filtername, zval *fi
data->persistent = persistent;
fops = &chunked_filter_ops;
- return php_stream_filter_alloc(fops, data, persistent, PSFS_SEEKABLE_START);
+ return php_stream_filter_alloc(fops, data, persistent, PSFS_SEEKABLE_START, PSFS_SEEKABLE_ALWAYS);
}
static const php_stream_filter_factory chunked_filter_factory = {
diff --git a/ext/standard/tests/filters/chunked_002.phpt b/ext/standard/tests/filters/chunked_002.phpt
new file mode 100644
index 00000000000..7114e278e3b
--- /dev/null
+++ b/ext/standard/tests/filters/chunked_002.phpt
@@ -0,0 +1,58 @@
+--TEST--
+Dechunk write filter state must survive stream seek
+--FILE--
+<?php
+/* The dechunk filter is commonly used as a write filter on php://temp buffers.
+ * The buffer is written to (through the filter) and then seeked to re-read
+ * the already-decoded output. Seeking the stream must NOT reset the write
+ * filter state, otherwise multi-chunk transfers break. */
+
+$buffer = fopen('php://temp', 'w+');
+stream_filter_append($buffer, 'dechunk', STREAM_FILTER_WRITE);
+
+/* Write first chunk */
+fwrite($buffer, "5\r\nHello\r\n");
+
+/* Read back decoded data; this seeks to offset 0 internally */
+$data = stream_get_contents($buffer, -1, 0);
+var_dump($data);
+
+/* Write second chunk; filter must still be in the correct state */
+fwrite($buffer, "7\r\n, World\r\n");
+
+/* Read all decoded data from the beginning */
+$data = stream_get_contents($buffer, -1, 0);
+var_dump($data);
+
+/* Write final (terminating) chunk */
+fwrite($buffer, "0\r\n\r\n");
+
+/* Read complete decoded output */
+$data = stream_get_contents($buffer, -1, 0);
+var_dump($data);
+
+fclose($buffer);
+
+/* Also verify that incomplete chunked transfer is still detected:
+ * writing a non-chunk byte after the filter has been reset by a
+ * seek should not produce output. */
+$buffer = fopen('php://temp', 'w+');
+stream_filter_append($buffer, 'dechunk', STREAM_FILTER_WRITE);
+
+fwrite($buffer, "5\r\nHello\r\n");
+$data = stream_get_contents($buffer, -1, 0);
+var_dump($data);
+
+/* The transfer is still in progress (no terminating 0-chunk seen).
+ * Verify incomplete state is preserved by checking ftell: the decoded
+ * write position should reflect only the 5 bytes written so far. */
+var_dump(ftell($buffer));
+
+fclose($buffer);
+?>
+--EXPECT--
+string(5) "Hello"
+string(12) "Hello, World"
+string(12) "Hello, World"
+string(5) "Hello"
+int(5)
diff --git a/ext/standard/tests/filters/convert_filter_write_seek_modes.phpt b/ext/standard/tests/filters/convert_filter_write_seek_modes.phpt
new file mode 100644
index 00000000000..0a2d4114bf3
--- /dev/null
+++ b/ext/standard/tests/filters/convert_filter_write_seek_modes.phpt
@@ -0,0 +1,61 @@
+--TEST--
+convert.* write filter: write_seek_mode parameter
+--FILE--
+<?php
+/* Smoke test: the write_seek_mode parameter is accepted on convert.* filters
+ * and behaves correctly per mode. The deeper reset semantics are exercised
+ * via the read path for convert.* (read_seekable = START always resets) and
+ * via the dedicated zlib/bz2 mode tests. */
+
+foreach (['convert.base64-encode', 'convert.quoted-printable-encode'] as $name) {
+ /* preserve: seeks succeed (default) */
+ $fp = fopen('php://memory', 'w+');
+ stream_filter_append($fp, $name, STREAM_FILTER_WRITE,
+ ['write_seek_mode' => 'preserve']);
+ fwrite($fp, 'Hello');
+ var_dump(fseek($fp, 0, SEEK_SET) === 0);
+ var_dump(fseek($fp, 100, SEEK_SET) === 0);
+ fclose($fp);
+
+ /* reset: seeks succeed, callback dispatched */
+ $fp = fopen('php://memory', 'w+');
+ stream_filter_append($fp, $name, STREAM_FILTER_WRITE,
+ ['write_seek_mode' => 'reset']);
+ fwrite($fp, 'Hello');
+ var_dump(fseek($fp, 0, SEEK_SET) === 0);
+ fclose($fp);
+
+ /* strict: seek fails */
+ $fp = fopen('php://memory', 'w+');
+ stream_filter_append($fp, $name, STREAM_FILTER_WRITE,
+ ['write_seek_mode' => 'strict']);
+ fwrite($fp, 'Hello');
+ var_dump(@fseek($fp, 0, SEEK_SET) === -1);
+ fclose($fp);
+
+ /* invalid: ValueError */
+ $fp = fopen('php://memory', 'w+');
+ stream_filter_append($fp, $name, STREAM_FILTER_WRITE,
+ ['write_seek_mode' => 42]);
+ if ($fp) {
+ fclose($fp);
+ }
+}
+?>
+--EXPECTF--
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+
+Warning: stream_filter_append(): "write_seek_mode" filter parameter must be one of "preserve", "reset", or "strict" in %s
+
+Warning: stream_filter_append(): Unable to create or locate filter "convert.base64-encode" in %s
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+
+Warning: stream_filter_append(): "write_seek_mode" filter parameter must be one of "preserve", "reset", or "strict" in %s
+
+Warning: stream_filter_append(): Unable to create or locate filter "convert.quoted-printable-encode" in %s
diff --git a/ext/standard/tests/filters/php_user_filter_04.phpt b/ext/standard/tests/filters/php_user_filter_04.phpt
index 72f874f21ad..6dc62779aa9 100644
--- a/ext/standard/tests/filters/php_user_filter_04.phpt
+++ b/ext/standard/tests/filters/php_user_filter_04.phpt
@@ -25,4 +25,4 @@ public function seek($offset): bool
?>
--EXPECTF--
-Fatal error: Declaration of InvalidSeekFilter::seek($offset): bool must be compatible with php_user_filter::seek(int $offset, int $whence): bool in %s on line %d
+Fatal error: Declaration of InvalidSeekFilter::seek($offset): bool must be compatible with php_user_filter::seek(int $offset, int $whence, int $chain): bool in %s on line %d
diff --git a/ext/standard/tests/filters/user_filter_seek_01.phpt b/ext/standard/tests/filters/user_filter_seek_01.phpt
index 31ec95ca6aa..6023ced1d0e 100644
--- a/ext/standard/tests/filters/user_filter_seek_01.phpt
+++ b/ext/standard/tests/filters/user_filter_seek_01.phpt
@@ -39,7 +39,7 @@ public function onCreate(): bool
public function onClose(): void {}
- public function seek(int $offset, int $whence): bool
+ public function seek(int $offset, int $whence, int $chain): bool
{
// Stateless filter - always seekable to any position
return true;
diff --git a/ext/standard/tests/filters/user_filter_seek_02.phpt b/ext/standard/tests/filters/user_filter_seek_02.phpt
index 39f4c3c6624..f728e75cdca 100644
--- a/ext/standard/tests/filters/user_filter_seek_02.phpt
+++ b/ext/standard/tests/filters/user_filter_seek_02.phpt
@@ -28,7 +28,7 @@ public function onCreate(): bool
public function onClose(): void {}
- public function seek(int $offset, int $whence): bool
+ public function seek(int $offset, int $whence, int $chain): bool
{
if ($offset === 0 && $whence === SEEK_SET) {
$this->count = 0;
diff --git a/ext/standard/tests/filters/user_filter_seek_03.phpt b/ext/standard/tests/filters/user_filter_seek_03.phpt
new file mode 100644
index 00000000000..bc814e8ed16
--- /dev/null
+++ b/ext/standard/tests/filters/user_filter_seek_03.phpt
@@ -0,0 +1,67 @@
+--TEST--
+php_user_filter::seek receives chain identifier (read vs write)
+--FILE--
+<?php
+
+class TrackingFilter extends php_user_filter
+{
+ public static array $log = [];
+
+ public function filter($in, $out, &$consumed, bool $closing): int
+ {
+ while ($bucket = stream_bucket_make_writeable($in)) {
+ $consumed += $bucket->datalen;
+ stream_bucket_append($out, $bucket);
+ }
+ return PSFS_PASS_ON;
+ }
+
+ public function onCreate(): bool
+ {
+ return true;
+ }
+
+ public function onClose(): void {}
+
+ public function seek(int $offset, int $whence, int $chain): bool
+ {
+ $name = match ($chain) {
+ STREAM_FILTER_READ => 'read',
+ STREAM_FILTER_WRITE => 'write',
+ default => 'other',
+ };
+ self::$log[] = "$name:$offset:$whence";
+ return true;
+ }
+}
+
+stream_filter_register('test.tracking', 'TrackingFilter');
+
+$file = __DIR__ . '/user_filter_seek_03.txt';
+file_put_contents($file, "abcdefghij");
+
+/* Read chain: seek-to-start should dispatch with STREAM_FILTER_READ */
+TrackingFilter::$log = [];
+$fp = fopen($file, 'r');
+stream_filter_append($fp, 'test.tracking', STREAM_FILTER_READ);
+fread($fp, 4);
+fseek($fp, 0, SEEK_SET);
+fclose($fp);
+echo "Read chain log: " . implode(',', TrackingFilter::$log) . "\n";
+
+/* Write chain: any seek should dispatch with STREAM_FILTER_WRITE */
+TrackingFilter::$log = [];
+$fp = fopen($file, 'w+');
+stream_filter_append($fp, 'test.tracking', STREAM_FILTER_WRITE);
+fwrite($fp, "xyz");
+fseek($fp, 0, SEEK_SET);
+fseek($fp, 5, SEEK_SET);
+fclose($fp);
+echo "Write chain log: " . implode(',', TrackingFilter::$log) . "\n";
+
+unlink($file);
+
+?>
+--EXPECT--
+Read chain log: read:0:0
+Write chain log: write:0:0,write:5:0
diff --git a/ext/standard/user_filters.c b/ext/standard/user_filters.c
index 816a798c4b0..ae4132733be 100644
--- a/ext/standard/user_filters.c
+++ b/ext/standard/user_filters.c
@@ -48,8 +48,9 @@ PHP_METHOD(php_user_filter, filter)
PHP_METHOD(php_user_filter, seek)
{
- zend_long offset, whence;
- if (zend_parse_parameters(ZEND_NUM_ARGS(), "ll", &offset, &whence) == FAILURE) {
+ zend_long offset, whence, chain;
+
+ if (zend_parse_parameters(ZEND_NUM_ARGS(), "lll", &offset, &whence, &chain) == FAILURE) {
RETURN_THROWS();
}
@@ -263,7 +264,7 @@ static zend_result userfilter_seek(
{
zval *obj = &thisfilter->abstract;
zval retval;
- zval args[2];
+ zval args[3];
/* the userfilter object probably doesn't exist anymore */
if (CG(unclean_shutdown)) {
@@ -289,8 +290,9 @@ static zend_result userfilter_seek(
/* Setup calling arguments */
ZVAL_LONG(&args[0], offset);
ZVAL_LONG(&args[1], whence);
+ ZVAL_LONG(&args[2], php_stream_filter_get_chain_type(stream, thisfilter));
- zend_call_known_function(seek_method, Z_OBJ_P(obj), Z_OBJCE_P(obj), &retval, 2, args, NULL);
+ zend_call_known_function(seek_method, Z_OBJ_P(obj), Z_OBJCE_P(obj), &retval, 3, args, NULL);
zend_result ret = FAILURE;
if (Z_TYPE(retval) != IS_UNDEF) {
@@ -383,7 +385,8 @@ static php_stream_filter *user_filter_factory_create(const char *filtername,
return NULL;
}
- filter = php_stream_filter_alloc(&userfilter_ops, NULL, false, PSFS_SEEKABLE_CHECK);
+ filter = php_stream_filter_alloc(&userfilter_ops, NULL, false,
+ PSFS_SEEKABLE_CHECK, PSFS_SEEKABLE_CHECK);
/* filtername */
add_property_string(&obj, "filtername", filtername);
diff --git a/ext/standard/user_filters.stub.php b/ext/standard/user_filters.stub.php
index 475ec58e79e..8696845b78c 100644
--- a/ext/standard/user_filters.stub.php
+++ b/ext/standard/user_filters.stub.php
@@ -50,7 +50,7 @@ class php_user_filter
public function filter($in, $out, &$consumed, bool $closing): int {}
/** @tentative-return-type */
- public function seek(int $offset, int $whence): bool {}
+ public function seek(int $offset, int $whence, int $chain): bool {}
/** @tentative-return-type */
public function onCreate(): bool {}
diff --git a/ext/standard/user_filters_arginfo.h b/ext/standard/user_filters_arginfo.h
index d530a5c0006..f5b8a7dfa47 100644
Binary files a/ext/standard/user_filters_arginfo.h and b/ext/standard/user_filters_arginfo.h differ
diff --git a/ext/zlib/tests/zlib_filter_seek_deflate.phpt b/ext/zlib/tests/zlib_filter_seek_deflate.phpt
index 6acee8e4e8c..743f1be566f 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--
<?php
+/* Write filters are not reset on stream seek; seeking only affects the
+ * stream's read/write position, not the filter pipeline state. This ensures
+ * seeking a stream with write filters does not disrupt the filter state. */
+
$file = __DIR__ . '/zlib_filter_seek_deflate.zlib';
-$text1 = 'Short text.';
-$text2 = 'This is a much longer text that will completely overwrite the previous compressed data in the file.';
+$text = 'Hello, World!';
$fp = fopen($file, 'w+');
-stream_filter_append($fp, 'zlib.deflate', STREAM_FILTER_WRITE);
+$filter = stream_filter_append($fp, 'zlib.deflate', STREAM_FILTER_WRITE);
+
+fwrite($fp, $text);
-fwrite($fp, $text1);
-fflush($fp);
+/* Remove the filter to finalize compression cleanly before seeking */
+stream_filter_remove($filter);
-$size1 = ftell($fp);
-echo "Size after first write: $size1\n";
+$size = ftell($fp);
+echo "Size after write: $size\n";
+/* Seek to start succeeds; write filters no longer block seeking */
$result = fseek($fp, 0, SEEK_SET);
echo "Seek to start: " . ($result === 0 ? "SUCCESS" : "FAILURE") . "\n";
-fwrite($fp, $text2);
-fflush($fp);
-
-$size2 = ftell($fp);
-echo "Size after second write: $size2\n";
-echo "Second write is larger: " . ($size2 > $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--
<?php
@unlink(__DIR__ . '/zlib_filter_seek_deflate.zlib');
?>
--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/ext/zlib/tests/zlib_filter_write_seek_modes.phpt b/ext/zlib/tests/zlib_filter_write_seek_modes.phpt
new file mode 100644
index 00000000000..39824a7283c
--- /dev/null
+++ b/ext/zlib/tests/zlib_filter_write_seek_modes.phpt
@@ -0,0 +1,78 @@
+--TEST--
+zlib.deflate write filter: write_seek_mode parameter
+--EXTENSIONS--
+zlib
+--FILE--
+<?php
+$file = __DIR__ . '/zlib_filter_write_seek_modes.zlib';
+
+$text1 = 'First message that will be discarded.';
+$text2 = 'Second message that replaces the first.';
+
+/* "reset": ftruncate(0) + fseek(0) starts a fresh deflate stream */
+$fp = fopen($file, 'w+');
+stream_filter_append($fp, 'zlib.deflate', STREAM_FILTER_WRITE,
+ ['write_seek_mode' => 'reset']);
+fwrite($fp, $text1);
+ftruncate($fp, 0);
+var_dump(fseek($fp, 0, SEEK_SET) === 0);
+fwrite($fp, $text2);
+fclose($fp);
+
+$fp = fopen($file, 'r');
+stream_filter_append($fp, 'zlib.inflate', STREAM_FILTER_READ);
+$decoded = stream_get_contents($fp);
+fclose($fp);
+var_dump($decoded === $text2);
+
+/* "reset": only seekable to start */
+$fp = fopen($file, 'w+');
+stream_filter_append($fp, 'zlib.deflate', STREAM_FILTER_WRITE,
+ ['write_seek_mode' => 'reset']);
+fwrite($fp, $text1);
+var_dump(fseek($fp, 5, SEEK_SET) === 0);
+fclose($fp);
+
+/* "preserve": same as default; seeks accepted, state preserved */
+$fp = fopen($file, 'w+');
+stream_filter_append($fp, 'zlib.deflate', STREAM_FILTER_WRITE,
+ ['write_seek_mode' => 'preserve']);
+fwrite($fp, $text1);
+var_dump(fseek($fp, 0, SEEK_SET) === 0);
+var_dump(fseek($fp, 50, SEEK_SET) === 0);
+fclose($fp);
+
+/* "strict": every write-chain seek fails */
+$fp = fopen($file, 'w+');
+stream_filter_append($fp, 'zlib.deflate', STREAM_FILTER_WRITE,
+ ['write_seek_mode' => 'strict']);
+fwrite($fp, $text1);
+var_dump(@fseek($fp, 0, SEEK_SET) === -1);
+var_dump(@fseek($fp, 50, SEEK_SET) === -1);
+fclose($fp);
+
+/* Invalid mode: ValueError */
+$fp = fopen($file, 'w+');
+stream_filter_append($fp, 'zlib.deflate', STREAM_FILTER_WRITE,
+ ['write_seek_mode' => 'rewind']);
+fclose($fp);
+
+?>
+--CLEAN--
+<?php
+@unlink(__DIR__ . '/zlib_filter_write_seek_modes.zlib');
+?>
+--EXPECTF--
+bool(true)
+bool(true)
+
+Warning: fseek(): Stream filter zlib.deflate is seekable only to start position in %s
+bool(false)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+
+Warning: stream_filter_append(): "write_seek_mode" filter parameter must be one of "preserve", "reset", or "strict" in %s
+
+Warning: stream_filter_append(): Unable to create or locate filter "zlib.deflate" in %s
diff --git a/ext/zlib/zlib_filter.c b/ext/zlib/zlib_filter.c
index b6393feb908..2d0e4fbb7fa 100644
--- a/ext/zlib/zlib_filter.c
+++ b/ext/zlib/zlib_filter.c
@@ -357,9 +357,14 @@ static const php_stream_filter_ops php_zlib_deflate_ops = {
static php_stream_filter *php_zlib_filter_create(const char *filtername, zval *filterparams, bool persistent)
{
const php_stream_filter_ops *fops = NULL;
+ php_stream_filter_seekable_t write_seekable;
php_zlib_filter_data *data;
int status;
+ if (php_stream_filter_parse_write_seek_mode(filterparams, &write_seekable) == FAILURE) {
+ return NULL;
+ }
+
/* Create this filter */
data = pecalloc(1, sizeof(php_zlib_filter_data), persistent);
if (!data) {
@@ -498,7 +503,7 @@ static php_stream_filter *php_zlib_filter_create(const char *filtername, zval *f
return NULL;
}
- return php_stream_filter_alloc(fops, data, persistent, PSFS_SEEKABLE_START);
+ return php_stream_filter_alloc(fops, data, persistent, PSFS_SEEKABLE_START, write_seekable);
}
const php_stream_filter_factory php_zlib_filter_factory = {
diff --git a/main/streams/filter.c b/main/streams/filter.c
index 9ec14419569..3a19f5ce918 100644
--- a/main/streams/filter.c
+++ b/main/streams/filter.c
@@ -259,7 +259,8 @@ PHPAPI php_stream_filter *php_stream_filter_create(const char *filtername, zval
}
PHPAPI php_stream_filter *_php_stream_filter_alloc(const php_stream_filter_ops *fops,
- void *abstract, bool persistent, php_stream_filter_seekable_t seekable STREAMS_DC)
+ void *abstract, bool persistent, php_stream_filter_seekable_t read_seekable,
+ php_stream_filter_seekable_t write_seekable STREAMS_DC)
{
php_stream_filter *filter;
@@ -267,13 +268,60 @@ PHPAPI php_stream_filter *_php_stream_filter_alloc(const php_stream_filter_ops *
memset(filter, 0, sizeof(php_stream_filter));
filter->fops = fops;
- filter->seekable = seekable;
+ filter->read_seekable = read_seekable;
+ filter->write_seekable = write_seekable;
Z_PTR(filter->abstract) = abstract;
filter->is_persistent = persistent;
return filter;
}
+PHPAPI zend_result php_stream_filter_parse_write_seek_mode(
+ zval *filterparams,
+ php_stream_filter_seekable_t *write_seekable)
+{
+ *write_seekable = PSFS_SEEKABLE_ALWAYS;
+
+ if (filterparams == NULL) {
+ return SUCCESS;
+ }
+ if (Z_TYPE_P(filterparams) != IS_ARRAY && Z_TYPE_P(filterparams) != IS_OBJECT) {
+ return SUCCESS;
+ }
+
+ zval *tmp = zend_hash_str_find_ind(HASH_OF(filterparams),
+ "write_seek_mode", sizeof("write_seek_mode") - 1);
+ if (tmp == NULL) {
+ return SUCCESS;
+ }
+
+ zend_string *tmp_str;
+ zend_string *str = zval_get_tmp_string(tmp, &tmp_str);
+ zend_result result = SUCCESS;
+
+ if (zend_string_equals_literal(str, "preserve")) {
+ *write_seekable = PSFS_SEEKABLE_ALWAYS;
+ } else if (zend_string_equals_literal(str, "reset")) {
+ *write_seekable = PSFS_SEEKABLE_START;
+ } else if (zend_string_equals_literal(str, "strict")) {
+ *write_seekable = PSFS_SEEKABLE_NEVER;
+ } else {
+ php_error_docref(NULL, E_WARNING,
+ "\"write_seek_mode\" filter parameter must be one of "
+ "\"preserve\", \"reset\", or \"strict\"");
+ result = FAILURE;
+ }
+
+ zend_tmp_string_release(tmp_str);
+ return result;
+}
+
+PHPAPI int php_stream_filter_get_chain_type(php_stream *stream, php_stream_filter *filter)
+{
+ return filter->chain == &stream->readfilters ?
+ PHP_STREAM_FILTER_READ : PHP_STREAM_FILTER_WRITE;
+}
+
PHPAPI void php_stream_filter_free(php_stream_filter *filter)
{
if (filter->fops->dtor)
diff --git a/main/streams/php_stream_filter_api.h b/main/streams/php_stream_filter_api.h
index a61bc48815f..111127a8ad1 100644
--- a/main/streams/php_stream_filter_api.h
+++ b/main/streams/php_stream_filter_api.h
@@ -118,7 +118,8 @@ struct _php_stream_filter {
zval abstract; /* for use by filter implementation */
php_stream_filter *next;
php_stream_filter *prev;
- php_stream_filter_seekable_t seekable;
+ php_stream_filter_seekable_t read_seekable;
+ php_stream_filter_seekable_t write_seekable;
bool is_persistent;
/* link into stream and chain */
@@ -141,13 +142,17 @@ PHPAPI zend_result _php_stream_filter_flush(php_stream_filter *filter, bool fini
PHPAPI php_stream_filter *php_stream_filter_remove(php_stream_filter *filter, bool call_dtor);
PHPAPI void php_stream_filter_free(php_stream_filter *filter);
PHPAPI php_stream_filter *_php_stream_filter_alloc(const php_stream_filter_ops *fops,
- void *abstract, bool persistent, php_stream_filter_seekable_t seekable STREAMS_DC);
+ void *abstract, bool persistent, php_stream_filter_seekable_t read_seekable,
+ php_stream_filter_seekable_t write_seekable STREAMS_DC);
+PHPAPI zend_result php_stream_filter_parse_write_seek_mode(zval *filterparams,
+ php_stream_filter_seekable_t *write_seekable);
+PHPAPI int php_stream_filter_get_chain_type(php_stream *stream, php_stream_filter *filter);
END_EXTERN_C()
-#define php_stream_filter_alloc(fops, thisptr, persistent, seekable) \
- _php_stream_filter_alloc((fops), (thisptr), (persistent), (seekable) STREAMS_CC)
-#define php_stream_filter_alloc_rel(fops, thisptr, persistent, seekable) \
- _php_stream_filter_alloc((fops), (thisptr), (persistent), (seekable) STREAMS_REL_CC)
+#define php_stream_filter_alloc(fops, thisptr, persistent, rseek, wseek) \
+ _php_stream_filter_alloc((fops), (thisptr), (persistent), (rseek), (wseek) STREAMS_CC)
+#define php_stream_filter_alloc_rel(fops, thisptr, persistent, rseek, wseek) \
+ _php_stream_filter_alloc((fops), (thisptr), (persistent), (rseek), (wseek) STREAMS_REL_CC)
#define php_stream_filter_prepend(chain, filter) _php_stream_filter_prepend((chain), (filter))
#define php_stream_filter_append(chain, filter) _php_stream_filter_append((chain), (filter))
#define php_stream_filter_flush(filter, finish) _php_stream_filter_flush((filter), (finish))
diff --git a/main/streams/streams.c b/main/streams/streams.c
index d7cff6cf8de..e638c52159a 100644
--- a/main/streams/streams.c
+++ b/main/streams/streams.c
@@ -1360,14 +1360,16 @@ PHPAPI zend_off_t _php_stream_tell(const php_stream *stream)
return stream->position;
}
-static bool php_stream_are_filters_seekable(php_stream_filter *filter, bool is_start_seeking)
+static bool php_stream_are_filters_seekable(php_stream_filter *filter, bool is_start_seeking, int chain_type)
{
while (filter) {
- if (filter->seekable == PSFS_SEEKABLE_NEVER) {
+ php_stream_filter_seekable_t seekable = (chain_type == PHP_STREAM_FILTER_READ) ?
+ filter->read_seekable : filter->write_seekable;
+ if (seekable == PSFS_SEEKABLE_NEVER) {
php_error_docref(NULL, E_WARNING, "Stream filter %s is never seekable", filter->fops->label);
return false;
}
- if (!is_start_seeking && filter->seekable == PSFS_SEEKABLE_START) {
+ if (!is_start_seeking && seekable == PSFS_SEEKABLE_START) {
php_error_docref(NULL, E_WARNING, "Stream filter %s is seekable only to start position", filter->fops->label);
return false;
}
@@ -1377,11 +1379,13 @@ static bool php_stream_are_filters_seekable(php_stream_filter *filter, bool is_s
}
static zend_result php_stream_filters_seek(php_stream *stream, php_stream_filter *filter,
- bool is_start_seeking, zend_off_t offset, int whence)
+ bool is_start_seeking, zend_off_t offset, int whence, int chain_type)
{
while (filter) {
- if (((filter->seekable == PSFS_SEEKABLE_START && is_start_seeking) ||
- filter->seekable == PSFS_SEEKABLE_CHECK) &&
+ php_stream_filter_seekable_t seekable = (chain_type == PHP_STREAM_FILTER_READ) ?
+ filter->read_seekable : filter->write_seekable;
+ if (((seekable == PSFS_SEEKABLE_START && is_start_seeking) ||
+ seekable == PSFS_SEEKABLE_CHECK) &&
filter->fops->seek(stream, filter, offset, whence) == FAILURE) {
php_error_docref(NULL, E_WARNING, "Stream filter seeking for %s failed", filter->fops->label);
return FAILURE;
@@ -1394,10 +1398,12 @@ 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) {
+ if (php_stream_filters_seek(stream, stream->writefilters.head, is_start_seeking,
+ offset, whence, PHP_STREAM_FILTER_WRITE) == FAILURE) {
return FAILURE;
}
- if (php_stream_filters_seek(stream, stream->readfilters.head, is_start_seeking, offset, whence) == FAILURE) {
+ if (php_stream_filters_seek(stream, stream->readfilters.head, is_start_seeking,
+ offset, whence, PHP_STREAM_FILTER_READ) == FAILURE) {
return FAILURE;
}
@@ -1422,11 +1428,13 @@ 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)) {
+ if (!php_stream_are_filters_seekable(stream->writefilters.head, is_start_seeking,
+ PHP_STREAM_FILTER_WRITE)) {
return -1;
}
}
- if (stream->readfilters.head && !php_stream_are_filters_seekable(stream->readfilters.head, is_start_seeking)) {
+ if (stream->readfilters.head && !php_stream_are_filters_seekable(
+ stream->readfilters.head, is_start_seeking, PHP_STREAM_FILTER_READ)) {
return -1;
}