Skip to content

Commit 7aba509

Browse files
committed
streams: split filter seekability into read and write fields
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.
1 parent dc807bc commit 7aba509

20 files changed

Lines changed: 483 additions & 85 deletions

ext/bz2/bz2_filter.c

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -381,9 +381,14 @@ static const php_stream_filter_ops php_bz2_compress_ops = {
381381
static php_stream_filter *php_bz2_filter_create(const char *filtername, zval *filterparams, bool persistent)
382382
{
383383
const php_stream_filter_ops *fops = NULL;
384+
php_stream_filter_seekable_t write_seekable;
384385
php_bz2_filter_data *data;
385386
int status = BZ_OK;
386387

388+
if (php_stream_filter_parse_write_seek_mode(filterparams, &write_seekable) == FAILURE) {
389+
return NULL;
390+
}
391+
387392
/* Create this filter */
388393
data = pecalloc(1, sizeof(php_bz2_filter_data), persistent);
389394

@@ -476,7 +481,7 @@ static php_stream_filter *php_bz2_filter_create(const char *filtername, zval *fi
476481
return NULL;
477482
}
478483

479-
return php_stream_filter_alloc(fops, data, persistent, PSFS_SEEKABLE_START);
484+
return php_stream_filter_alloc(fops, data, persistent, PSFS_SEEKABLE_START, write_seekable);
480485
}
481486

482487
const php_stream_filter_factory php_bz2_filter_factory = {
Lines changed: 19 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,51 @@
11
--TEST--
2-
bzip2.compress filter with seek to start
2+
bzip2.compress write filter is not reset on seek
33
--EXTENSIONS--
44
bz2
55
--FILE--
66
<?php
7+
/* Write filters are not reset on stream seek; seeking only affects the
8+
* stream's read/write position, not the filter pipeline state. */
9+
710
$file = __DIR__ . '/bz2_filter_seek_compress.bz2';
811

9-
$text1 = 'Short text.';
10-
$text2 = 'This is a much longer text that will completely overwrite the previous compressed data in the file.';
12+
$text = 'Hello, World!';
1113

1214
$fp = fopen($file, 'w+');
13-
stream_filter_append($fp, 'bzip2.compress', STREAM_FILTER_WRITE);
15+
$filter = stream_filter_append($fp, 'bzip2.compress', STREAM_FILTER_WRITE);
16+
17+
fwrite($fp, $text);
1418

15-
fwrite($fp, $text1);
16-
fflush($fp);
19+
/* Remove the filter to finalize compression cleanly before seeking */
20+
stream_filter_remove($filter);
1721

18-
$size1 = ftell($fp);
19-
echo "Size after first write: $size1\n";
22+
$size = ftell($fp);
23+
echo "Size after write: $size\n";
2024

25+
/* Seek to start succeeds; write filters no longer block seeking */
2126
$result = fseek($fp, 0, SEEK_SET);
2227
echo "Seek to start: " . ($result === 0 ? "SUCCESS" : "FAILURE") . "\n";
2328

24-
fwrite($fp, $text2);
25-
fflush($fp);
26-
27-
$size2 = ftell($fp);
28-
echo "Size after second write: $size2\n";
29-
echo "Second write is larger: " . ($size2 > $size1 ? "YES" : "NO") . "\n";
30-
29+
/* Seek to middle also succeeds */
3130
$result = fseek($fp, 50, SEEK_SET);
3231
echo "Seek to middle: " . ($result === 0 ? "SUCCESS" : "FAILURE") . "\n";
3332

3433
fclose($fp);
3534

35+
/* Verify the compressed output is still valid */
3636
$fp = fopen($file, 'r');
3737
stream_filter_append($fp, 'bzip2.decompress', STREAM_FILTER_READ);
3838
$content = stream_get_contents($fp);
3939
fclose($fp);
4040

41-
echo "Decompressed content matches text2: " . ($content === $text2 ? "YES" : "NO") . "\n";
41+
echo "Decompressed content matches: " . ($content === $text ? "YES" : "NO") . "\n";
4242
?>
4343
--CLEAN--
4444
<?php
4545
@unlink(__DIR__ . '/bz2_filter_seek_compress.bz2');
4646
?>
4747
--EXPECTF--
48-
Size after first write: 40
48+
Size after write: %d
4949
Seek to start: SUCCESS
50-
Size after second write: 98
51-
Second write is larger: YES
52-
53-
Warning: fseek(): Stream filter bzip2.compress is seekable only to start position in %s on line %d
54-
Seek to middle: FAILURE
55-
Decompressed content matches text2: YES
50+
Seek to middle: SUCCESS
51+
Decompressed content matches: YES
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
--TEST--
2+
bzip2.compress write filter: write_seek_mode parameter
3+
--EXTENSIONS--
4+
bz2
5+
--FILE--
6+
<?php
7+
$file = __DIR__ . '/bz2_filter_write_seek_modes.bz2';
8+
9+
$text1 = 'First message that will be discarded.';
10+
$text2 = 'Second message that replaces the first.';
11+
12+
/* "reset" */
13+
$fp = fopen($file, 'w+');
14+
stream_filter_append($fp, 'bzip2.compress', STREAM_FILTER_WRITE,
15+
['write_seek_mode' => 'reset']);
16+
fwrite($fp, $text1);
17+
ftruncate($fp, 0);
18+
var_dump(fseek($fp, 0, SEEK_SET) === 0);
19+
fwrite($fp, $text2);
20+
fclose($fp);
21+
22+
$fp = fopen($file, 'r');
23+
stream_filter_append($fp, 'bzip2.decompress', STREAM_FILTER_READ);
24+
$decoded = stream_get_contents($fp);
25+
fclose($fp);
26+
var_dump($decoded === $text2);
27+
28+
/* "strict" */
29+
$fp = fopen($file, 'w+');
30+
stream_filter_append($fp, 'bzip2.compress', STREAM_FILTER_WRITE,
31+
['write_seek_mode' => 'strict']);
32+
fwrite($fp, $text1);
33+
var_dump(@fseek($fp, 0, SEEK_SET) === -1);
34+
fclose($fp);
35+
36+
/* Invalid mode: ValueError */
37+
$fp = fopen($file, 'w+');
38+
stream_filter_append($fp, 'bzip2.compress', STREAM_FILTER_WRITE,
39+
['write_seek_mode' => 'nope']);
40+
fclose($fp);
41+
42+
?>
43+
--CLEAN--
44+
<?php
45+
@unlink(__DIR__ . '/bz2_filter_write_seek_modes.bz2');
46+
?>
47+
--EXPECTF--
48+
bool(true)
49+
bool(true)
50+
bool(true)
51+
52+
Warning: stream_filter_append(): "write_seek_mode" filter parameter must be one of "preserve", "reset", or "strict" in %s
53+
54+
Warning: stream_filter_append(): Unable to create or locate filter "bzip2.compress" in %s

ext/iconv/iconv.c

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2633,9 +2633,14 @@ static const php_stream_filter_ops php_iconv_stream_filter_ops = {
26332633
static php_stream_filter *php_iconv_stream_filter_factory_create(const char *name, zval *params, bool persistent)
26342634
{
26352635
php_iconv_stream_filter *inst;
2636+
php_stream_filter_seekable_t write_seekable;
26362637
const char *from_charset = NULL, *to_charset = NULL;
26372638
size_t from_charset_len, to_charset_len;
26382639

2640+
if (php_stream_filter_parse_write_seek_mode(params, &write_seekable) == FAILURE) {
2641+
return NULL;
2642+
}
2643+
26392644
if ((from_charset = strchr(name, '.')) == NULL) {
26402645
return NULL;
26412646
}
@@ -2663,7 +2668,7 @@ static php_stream_filter *php_iconv_stream_filter_factory_create(const char *nam
26632668
}
26642669

26652670
return php_stream_filter_alloc(&php_iconv_stream_filter_ops, inst, persistent,
2666-
PSFS_SEEKABLE_START);
2671+
PSFS_SEEKABLE_START, write_seekable);
26672672
}
26682673
/* }}} */
26692674

ext/standard/filters.c

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,8 @@ static const php_stream_filter_ops strfilter_rot13_ops = {
6363

6464
static php_stream_filter *strfilter_rot13_create(const char *filtername, zval *filterparams, bool persistent)
6565
{
66-
return php_stream_filter_alloc(&strfilter_rot13_ops, NULL, persistent, PSFS_SEEKABLE_ALWAYS);
66+
return php_stream_filter_alloc(&strfilter_rot13_ops, NULL, persistent,
67+
PSFS_SEEKABLE_ALWAYS, PSFS_SEEKABLE_ALWAYS);
6768
}
6869

6970
static const php_stream_filter_factory strfilter_rot13_factory = {
@@ -147,12 +148,14 @@ static const php_stream_filter_ops strfilter_tolower_ops = {
147148

148149
static php_stream_filter *strfilter_toupper_create(const char *filtername, zval *filterparams, bool persistent)
149150
{
150-
return php_stream_filter_alloc(&strfilter_toupper_ops, NULL, persistent, PSFS_SEEKABLE_ALWAYS);
151+
return php_stream_filter_alloc(&strfilter_toupper_ops, NULL, persistent,
152+
PSFS_SEEKABLE_ALWAYS, PSFS_SEEKABLE_ALWAYS);
151153
}
152154

153155
static php_stream_filter *strfilter_tolower_create(const char *filtername, zval *filterparams, bool persistent)
154156
{
155-
return php_stream_filter_alloc(&strfilter_tolower_ops, NULL, persistent, PSFS_SEEKABLE_ALWAYS);
157+
return php_stream_filter_alloc(&strfilter_tolower_ops, NULL, persistent,
158+
PSFS_SEEKABLE_ALWAYS, PSFS_SEEKABLE_ALWAYS);
156159
}
157160

158161
static const php_stream_filter_factory strfilter_toupper_factory = {
@@ -1634,7 +1637,7 @@ static const php_stream_filter_ops strfilter_convert_ops = {
16341637
static php_stream_filter *strfilter_convert_create(const char *filtername, zval *filterparams, bool persistent)
16351638
{
16361639
php_convert_filter *inst;
1637-
1640+
php_stream_filter_seekable_t write_seekable;
16381641
const char *dot;
16391642
int conv_mode = 0;
16401643

@@ -1648,6 +1651,10 @@ static php_stream_filter *strfilter_convert_create(const char *filtername, zval
16481651
}
16491652
++dot;
16501653

1654+
if (php_stream_filter_parse_write_seek_mode(filterparams, &write_seekable) == FAILURE) {
1655+
return NULL;
1656+
}
1657+
16511658
inst = pemalloc(sizeof(php_convert_filter), persistent);
16521659

16531660
if (strcasecmp(dot, "base64-encode") == 0) {
@@ -1667,7 +1674,7 @@ static php_stream_filter *strfilter_convert_create(const char *filtername, zval
16671674
return NULL;
16681675
}
16691676

1670-
return php_stream_filter_alloc(&strfilter_convert_ops, inst, persistent, PSFS_SEEKABLE_START);
1677+
return php_stream_filter_alloc(&strfilter_convert_ops, inst, persistent, PSFS_SEEKABLE_START, write_seekable);
16711678
}
16721679

16731680
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
17611768
data->offset = ~0;
17621769
fops = &consumed_filter_ops;
17631770

1764-
return php_stream_filter_alloc(fops, data, persistent, PSFS_SEEKABLE_START);
1771+
return php_stream_filter_alloc(fops, data, persistent, PSFS_SEEKABLE_START, PSFS_SEEKABLE_ALWAYS);
17651772
}
17661773

17671774
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
19921999
data->persistent = persistent;
19932000
fops = &chunked_filter_ops;
19942001

1995-
return php_stream_filter_alloc(fops, data, persistent, PSFS_SEEKABLE_START);
2002+
return php_stream_filter_alloc(fops, data, persistent, PSFS_SEEKABLE_START, PSFS_SEEKABLE_ALWAYS);
19962003
}
19972004

19982005
static const php_stream_filter_factory chunked_filter_factory = {
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
--TEST--
2+
Dechunk write filter state must survive stream seek
3+
--FILE--
4+
<?php
5+
/* The dechunk filter is commonly used as a write filter on php://temp buffers.
6+
* The buffer is written to (through the filter) and then seeked to re-read
7+
* the already-decoded output. Seeking the stream must NOT reset the write
8+
* filter state, otherwise multi-chunk transfers break. */
9+
10+
$buffer = fopen('php://temp', 'w+');
11+
stream_filter_append($buffer, 'dechunk', STREAM_FILTER_WRITE);
12+
13+
/* Write first chunk */
14+
fwrite($buffer, "5\r\nHello\r\n");
15+
16+
/* Read back decoded data; this seeks to offset 0 internally */
17+
$data = stream_get_contents($buffer, -1, 0);
18+
var_dump($data);
19+
20+
/* Write second chunk; filter must still be in the correct state */
21+
fwrite($buffer, "7\r\n, World\r\n");
22+
23+
/* Read all decoded data from the beginning */
24+
$data = stream_get_contents($buffer, -1, 0);
25+
var_dump($data);
26+
27+
/* Write final (terminating) chunk */
28+
fwrite($buffer, "0\r\n\r\n");
29+
30+
/* Read complete decoded output */
31+
$data = stream_get_contents($buffer, -1, 0);
32+
var_dump($data);
33+
34+
fclose($buffer);
35+
36+
/* Also verify that incomplete chunked transfer is still detected:
37+
* writing a non-chunk byte after the filter has been reset by a
38+
* seek should not produce output. */
39+
$buffer = fopen('php://temp', 'w+');
40+
stream_filter_append($buffer, 'dechunk', STREAM_FILTER_WRITE);
41+
42+
fwrite($buffer, "5\r\nHello\r\n");
43+
$data = stream_get_contents($buffer, -1, 0);
44+
var_dump($data);
45+
46+
/* The transfer is still in progress (no terminating 0-chunk seen).
47+
* Verify incomplete state is preserved by checking ftell: the decoded
48+
* write position should reflect only the 5 bytes written so far. */
49+
var_dump(ftell($buffer));
50+
51+
fclose($buffer);
52+
?>
53+
--EXPECT--
54+
string(5) "Hello"
55+
string(12) "Hello, World"
56+
string(12) "Hello, World"
57+
string(5) "Hello"
58+
int(5)
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
--TEST--
2+
convert.* write filter: write_seek_mode parameter
3+
--FILE--
4+
<?php
5+
/* Smoke test: the write_seek_mode parameter is accepted on convert.* filters
6+
* and behaves correctly per mode. The deeper reset semantics are exercised
7+
* via the read path for convert.* (read_seekable = START always resets) and
8+
* via the dedicated zlib/bz2 mode tests. */
9+
10+
foreach (['convert.base64-encode', 'convert.quoted-printable-encode'] as $name) {
11+
/* preserve: seeks succeed (default) */
12+
$fp = fopen('php://memory', 'w+');
13+
stream_filter_append($fp, $name, STREAM_FILTER_WRITE,
14+
['write_seek_mode' => 'preserve']);
15+
fwrite($fp, 'Hello');
16+
var_dump(fseek($fp, 0, SEEK_SET) === 0);
17+
var_dump(fseek($fp, 100, SEEK_SET) === 0);
18+
fclose($fp);
19+
20+
/* reset: seeks succeed, callback dispatched */
21+
$fp = fopen('php://memory', 'w+');
22+
stream_filter_append($fp, $name, STREAM_FILTER_WRITE,
23+
['write_seek_mode' => 'reset']);
24+
fwrite($fp, 'Hello');
25+
var_dump(fseek($fp, 0, SEEK_SET) === 0);
26+
fclose($fp);
27+
28+
/* strict: seek fails */
29+
$fp = fopen('php://memory', 'w+');
30+
stream_filter_append($fp, $name, STREAM_FILTER_WRITE,
31+
['write_seek_mode' => 'strict']);
32+
fwrite($fp, 'Hello');
33+
var_dump(@fseek($fp, 0, SEEK_SET) === -1);
34+
fclose($fp);
35+
36+
/* invalid: ValueError */
37+
$fp = fopen('php://memory', 'w+');
38+
stream_filter_append($fp, $name, STREAM_FILTER_WRITE,
39+
['write_seek_mode' => 42]);
40+
if ($fp) {
41+
fclose($fp);
42+
}
43+
}
44+
?>
45+
--EXPECTF--
46+
bool(true)
47+
bool(true)
48+
bool(true)
49+
bool(true)
50+
51+
Warning: stream_filter_append(): "write_seek_mode" filter parameter must be one of "preserve", "reset", or "strict" in %s
52+
53+
Warning: stream_filter_append(): Unable to create or locate filter "convert.base64-encode" in %s
54+
bool(true)
55+
bool(true)
56+
bool(true)
57+
bool(true)
58+
59+
Warning: stream_filter_append(): "write_seek_mode" filter parameter must be one of "preserve", "reset", or "strict" in %s
60+
61+
Warning: stream_filter_append(): Unable to create or locate filter "convert.quoted-printable-encode" in %s

ext/standard/tests/filters/php_user_filter_04.phpt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,4 @@ class InvalidSeekFilter extends php_user_filter
2525

2626
?>
2727
--EXPECTF--
28-
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
28+
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

ext/standard/tests/filters/user_filter_seek_01.phpt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ class RotateFilter extends php_user_filter
3939

4040
public function onClose(): void {}
4141

42-
public function seek(int $offset, int $whence): bool
42+
public function seek(int $offset, int $whence, int $chain): bool
4343
{
4444
// Stateless filter - always seekable to any position
4545
return true;

ext/standard/tests/filters/user_filter_seek_02.phpt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ class CountingFilter extends php_user_filter
2828

2929
public function onClose(): void {}
3030

31-
public function seek(int $offset, int $whence): bool
31+
public function seek(int $offset, int $whence, int $chain): bool
3232
{
3333
if ($offset === 0 && $whence === SEEK_SET) {
3434
$this->count = 0;

0 commit comments

Comments
 (0)