From c4c3bed2c7a0b41cd207b01e8f0405991a92246c Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Wed, 8 Apr 2026 17:56:49 -0400 Subject: [PATCH 1/4] Fix GH-8562: SplFileObject::current() returns wrong value after next() SplFileObject::next() without READ_AHEAD cleared the cached line and incremented current_line_num but didn't advance the stream. When called without a preceding current() (e.g. rewind() then next()), the stream position stayed put, so the subsequent current() read stale data. Read a line to advance the stream when next() is called with no cached line. Closes GH-8562 --- ext/spl/spl_directory.c | 4 ++++ ext/spl/tests/gh8562.phpt | 27 +++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 ext/spl/tests/gh8562.phpt diff --git a/ext/spl/spl_directory.c b/ext/spl/spl_directory.c index daaba27cbfc6..da83104b2729 100644 --- a/ext/spl/spl_directory.c +++ b/ext/spl/spl_directory.c @@ -2142,6 +2142,10 @@ PHP_METHOD(SplFileObject, next) ZEND_PARSE_PARAMETERS_NONE(); + if (!intern->u.file.current_line && Z_ISUNDEF(intern->u.file.current_zval)) { + spl_filesystem_file_read_line(ZEND_THIS, intern, true); + } + spl_filesystem_file_free_line(intern); if (SPL_HAS_FLAG(intern->flags, SPL_FILE_OBJECT_READ_AHEAD)) { spl_filesystem_file_read_line(ZEND_THIS, intern, true); diff --git a/ext/spl/tests/gh8562.phpt b/ext/spl/tests/gh8562.phpt new file mode 100644 index 000000000000..40b2554f5794 --- /dev/null +++ b/ext/spl/tests/gh8562.phpt @@ -0,0 +1,27 @@ +--TEST-- +GH-8562 (SplFileObject::current() returns wrong result after call to next()) +--FILE-- +fwrite("line {$i}" . PHP_EOL); +} + +$file->rewind(); +$file->next(); +echo "After rewind+next: key=" . $file->key() . " current=" . trim($file->current()) . "\n"; + +$file->rewind(); +$file->next(); +$file->next(); +echo "After rewind+next+next: key=" . $file->key() . " current=" . trim($file->current()) . "\n"; + +$file->rewind(); +$file->current(); +$file->next(); +echo "After current+next: key=" . $file->key() . " current=" . trim($file->current()) . "\n"; +?> +--EXPECT-- +After rewind+next: key=1 current=line 1 +After rewind+next+next: key=2 current=line 2 +After current+next: key=1 current=line 1 From 6035b0aa9f53b12f0e694cb7dca337f2bbacdcf0 Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Thu, 9 Apr 2026 10:56:45 -0400 Subject: [PATCH 2/4] Fix GH-8561: SplFileObject key()/current() desync after fgets() fgets() read a line into the cache and incremented the line counter, but left the cached line in place. The subsequent current() returned the stale cached value instead of reading the next line from the stream's actual position. Clear the cached line after fgets() copies it to the return value. This forces current() to re-read from the stream, which has already advanced past the fgets'd line. Closes GH-8561 --- ext/spl/spl_directory.c | 3 ++- ext/spl/tests/SplFileObject/gh8561.phpt | 30 +++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 ext/spl/tests/SplFileObject/gh8561.phpt diff --git a/ext/spl/spl_directory.c b/ext/spl/spl_directory.c index da83104b2729..71e3f445a5ab 100644 --- a/ext/spl/spl_directory.c +++ b/ext/spl/spl_directory.c @@ -2096,7 +2096,8 @@ PHP_METHOD(SplFileObject, fgets) if (spl_filesystem_file_read_ex(intern, /* silent */ false, /* line_add */ 1, /* csv */ false) == FAILURE) { RETURN_THROWS(); } - RETURN_STR_COPY(intern->u.file.current_line); + RETVAL_STR_COPY(intern->u.file.current_line); + spl_filesystem_file_free_line(intern); } /* }}} */ /* {{{ Return current line from file */ diff --git a/ext/spl/tests/SplFileObject/gh8561.phpt b/ext/spl/tests/SplFileObject/gh8561.phpt new file mode 100644 index 000000000000..9451112b939e --- /dev/null +++ b/ext/spl/tests/SplFileObject/gh8561.phpt @@ -0,0 +1,30 @@ +--TEST-- +GH-8561 (SplFileObject: key() and current() unsynchronized after fgets()) +--FILE-- +fwrite("line {$i}" . PHP_EOL); +} + +// Case 1: rewind + fgets, then key/current +$file->rewind(); +$file->fgets(); +echo "After rewind+fgets: key=" . $file->key() . " current=" . trim($file->current()) . "\n"; + +// Case 2: multiple fgets +$file->rewind(); +$file->fgets(); +$file->fgets(); +echo "After rewind+fgets+fgets: key=" . $file->key() . " current=" . trim($file->current()) . "\n"; + +// Case 3: current then fgets +$file->rewind(); +$file->current(); +$file->fgets(); +echo "After current+fgets: key=" . $file->key() . " current=" . trim($file->current()) . "\n"; +?> +--EXPECT-- +After rewind+fgets: key=1 current=line 1 +After rewind+fgets+fgets: key=2 current=line 2 +After current+fgets: key=1 current=line 2 From 035355771a141a6f6dc0dbe55127b41612e78a1d Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Thu, 9 Apr 2026 11:13:59 -0400 Subject: [PATCH 3/4] Fix GH-8563, GH-8564: SplFileObject EOF handling for seek() and next() spl_filesystem_file_read_ex() treated a NULL buffer from php_stream_get_line as a successful read of an empty line, creating a phantom cached line at EOF. This caused seek() to give inconsistent results between SplFileObject and SplTempFileObject (GH-8563), and next() to increment the line counter indefinitely past EOF (GH-8564). Return FAILURE when php_stream_get_line returns NULL, matching the behavior of the existing php_stream_eof check. In seek(), break out of the loop on EOF instead of returning, so the post-loop cleanup runs consistently. In next(), return early at EOF without incrementing. Make __toString() return empty string at EOF instead of throwing. Closes GH-8563 Closes GH-8564 --- ext/spl/spl_directory.c | 36 ++++++++++--------- .../SplFileObject_key_error001.phpt | 2 +- .../SplFileObject_key_error002.phpt | 2 +- ext/spl/tests/SplFileObject/bug81477.phpt | 1 - .../SplFileObject/fgetcsv_blank_file.phpt | 5 +-- ext/spl/tests/SplFileObject/gh8563.phpt | 29 +++++++++++++++ ext/spl/tests/SplFileObject/gh8564.phpt | 20 +++++++++++ ext/spl/tests/gh13685.phpt | 4 +-- 8 files changed, 74 insertions(+), 25 deletions(-) create mode 100644 ext/spl/tests/SplFileObject/gh8563.phpt create mode 100644 ext/spl/tests/SplFileObject/gh8564.phpt diff --git a/ext/spl/spl_directory.c b/ext/spl/spl_directory.c index 71e3f445a5ab..a51aaa5b19e3 100644 --- a/ext/spl/spl_directory.c +++ b/ext/spl/spl_directory.c @@ -1813,21 +1813,24 @@ static zend_result spl_filesystem_file_read_ex(spl_filesystem_object *intern, bo } if (!buf) { - intern->u.file.current_line = ZSTR_EMPTY_ALLOC(); - } else { - if (!csv && SPL_HAS_FLAG(intern->flags, SPL_FILE_OBJECT_DROP_NEW_LINE)) { - if (line_len > 0 && buf[line_len - 1] == '\n') { + if (!silent) { + spl_filesystem_file_cannot_read(intern); + } + return FAILURE; + } + + if (!csv && SPL_HAS_FLAG(intern->flags, SPL_FILE_OBJECT_DROP_NEW_LINE)) { + if (line_len > 0 && buf[line_len - 1] == '\n') { + line_len--; + if (line_len > 0 && buf[line_len - 1] == '\r') { line_len--; - if (line_len > 0 && buf[line_len - 1] == '\r') { - line_len--; - } - buf[line_len] = '\0'; } + buf[line_len] = '\0'; } - - intern->u.file.current_line = zend_string_init(buf, line_len, /* persistent */ false); - efree(buf); } + + intern->u.file.current_line = zend_string_init(buf, line_len, /* persistent */ false); + efree(buf); intern->u.file.current_line_num += line_add; return SUCCESS; @@ -2144,7 +2147,9 @@ PHP_METHOD(SplFileObject, next) ZEND_PARSE_PARAMETERS_NONE(); if (!intern->u.file.current_line && Z_ISUNDEF(intern->u.file.current_zval)) { - spl_filesystem_file_read_line(ZEND_THIS, intern, true); + if (spl_filesystem_file_read_line(ZEND_THIS, intern, true) == FAILURE) { + return; + } } spl_filesystem_file_free_line(intern); @@ -2634,7 +2639,7 @@ PHP_METHOD(SplFileObject, seek) for (i = 0; i < line_pos; i++) { if (spl_filesystem_file_read_line(ZEND_THIS, intern, true) == FAILURE) { - return; + break; } } if (line_pos > 0 && !SPL_HAS_FLAG(intern->flags, SPL_FILE_OBJECT_READ_AHEAD)) { @@ -2653,9 +2658,8 @@ PHP_METHOD(SplFileObject, __toString) if (!intern->u.file.current_line) { ZEND_ASSERT(Z_ISUNDEF(intern->u.file.current_zval)); - zend_result result = spl_filesystem_file_read_line(ZEND_THIS, intern, false); - if (UNEXPECTED(result != SUCCESS)) { - RETURN_THROWS(); + if (spl_filesystem_file_read_line(ZEND_THIS, intern, true) == FAILURE) { + RETURN_EMPTY_STRING(); } } diff --git a/ext/spl/tests/SplFileObject/SplFileObject_key_error001.phpt b/ext/spl/tests/SplFileObject/SplFileObject_key_error001.phpt index 0c21d0b905e9..7d0e3ae8d969 100644 --- a/ext/spl/tests/SplFileObject/SplFileObject_key_error001.phpt +++ b/ext/spl/tests/SplFileObject/SplFileObject_key_error001.phpt @@ -18,5 +18,5 @@ var_dump($s->key()); var_dump($s->valid()); ?> --EXPECT-- -int(14) +int(12) bool(false) diff --git a/ext/spl/tests/SplFileObject/SplFileObject_key_error002.phpt b/ext/spl/tests/SplFileObject/SplFileObject_key_error002.phpt index 8fc9b7fef0a5..0834dbc0524f 100644 --- a/ext/spl/tests/SplFileObject/SplFileObject_key_error002.phpt +++ b/ext/spl/tests/SplFileObject/SplFileObject_key_error002.phpt @@ -18,5 +18,5 @@ var_dump($s->key()); var_dump($s->valid()); ?> --EXPECT-- -int(13) +int(12) bool(false) diff --git a/ext/spl/tests/SplFileObject/bug81477.phpt b/ext/spl/tests/SplFileObject/bug81477.phpt index f7730a791aa0..421c74dc4d68 100644 --- a/ext/spl/tests/SplFileObject/bug81477.phpt +++ b/ext/spl/tests/SplFileObject/bug81477.phpt @@ -21,7 +21,6 @@ string(8) "baz,bat " string(10) "more,data " -string(0) "" --CLEAN-- rewind(); var_dump($file->fgetcsv()); ?> --EXPECT-- -array(1) { - [0]=> - NULL -} +bool(false) bool(false) diff --git a/ext/spl/tests/SplFileObject/gh8563.phpt b/ext/spl/tests/SplFileObject/gh8563.phpt new file mode 100644 index 000000000000..03891750f8bf --- /dev/null +++ b/ext/spl/tests/SplFileObject/gh8563.phpt @@ -0,0 +1,29 @@ +--TEST-- +GH-8563 (Different results for seek() on SplFileObject and SplTempFileObject) +--FILE-- +fwrite("line {$i}" . PHP_EOL); + $file_02->fwrite("line {$i}" . PHP_EOL); +} + +$file_01->rewind(); +$file_02->rewind(); + +$file_01->seek(10); +$file_02->seek(10); + +echo 'SplFileObject: ' . $file_01->key() . "\n"; +echo 'SplTempFileObject: ' . $file_02->key() . "\n"; +?> +--CLEAN-- + +--EXPECT-- +SplFileObject: 5 +SplTempFileObject: 5 diff --git a/ext/spl/tests/SplFileObject/gh8564.phpt b/ext/spl/tests/SplFileObject/gh8564.phpt new file mode 100644 index 000000000000..ff16893c4c6b --- /dev/null +++ b/ext/spl/tests/SplFileObject/gh8564.phpt @@ -0,0 +1,20 @@ +--TEST-- +GH-8564 (SplFileObject: next() moves to nonexistent indexes) +--FILE-- +fwrite("line {$i}" . PHP_EOL); +} + +$file->rewind(); +for ($i = 0; $i < 10; $file->next(), $i++); +echo "next() 10x: key=" . $file->key() . " valid=" . var_export($file->valid(), true) . "\n"; + +$file->rewind(); +$file->seek(10); +echo "seek(10): key=" . $file->key() . " valid=" . var_export($file->valid(), true) . "\n"; +?> +--EXPECT-- +next() 10x: key=5 valid=false +seek(10): key=5 valid=false diff --git a/ext/spl/tests/gh13685.phpt b/ext/spl/tests/gh13685.phpt index 0f679d0e93fc..2bdddec4584e 100644 --- a/ext/spl/tests/gh13685.phpt +++ b/ext/spl/tests/gh13685.phpt @@ -44,9 +44,9 @@ try { string(14) ""A", "B", "C" " string(13) ""D", "E", "F"" -Cannot read from file php://temp +string(0) "" --- Use csv control --- string(14) ""A", "B", "C" " string(13) ""D", "E", "F"" -Cannot read from file php://temp +string(0) "" From e3c8189f5e05fbf72a0fda6260b0526dba343b96 Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Fri, 10 Apr 2026 07:32:42 -0400 Subject: [PATCH 4/4] Refine fgets() to reuse cached line when present When current() reads a line into the cache without advancing line_num, a subsequent fgets() would re-read the stream and return the next line, skipping the cached one and leaving key() out of sync with current() for the rest of the iteration. Use the cached line if present; otherwise read a fresh line. Either way, advance line_num by one. --- ext/spl/spl_directory.c | 14 ++++++++++---- ext/spl/tests/SplFileObject/gh8561.phpt | 2 +- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/ext/spl/spl_directory.c b/ext/spl/spl_directory.c index a51aaa5b19e3..f204a4051cc9 100644 --- a/ext/spl/spl_directory.c +++ b/ext/spl/spl_directory.c @@ -2096,11 +2096,17 @@ PHP_METHOD(SplFileObject, fgets) CHECK_SPL_FILE_OBJECT_IS_INITIALIZED(intern); - if (spl_filesystem_file_read_ex(intern, /* silent */ false, /* line_add */ 1, /* csv */ false) == FAILURE) { - RETURN_THROWS(); + if (intern->u.file.current_line) { + RETVAL_STR_COPY(intern->u.file.current_line); + spl_filesystem_file_free_line(intern); + intern->u.file.current_line_num++; + } else { + if (spl_filesystem_file_read_ex(intern, /* silent */ false, /* line_add */ 1, /* csv */ false) == FAILURE) { + RETURN_THROWS(); + } + RETVAL_STR_COPY(intern->u.file.current_line); + spl_filesystem_file_free_line(intern); } - RETVAL_STR_COPY(intern->u.file.current_line); - spl_filesystem_file_free_line(intern); } /* }}} */ /* {{{ Return current line from file */ diff --git a/ext/spl/tests/SplFileObject/gh8561.phpt b/ext/spl/tests/SplFileObject/gh8561.phpt index 9451112b939e..adf36afb8b29 100644 --- a/ext/spl/tests/SplFileObject/gh8561.phpt +++ b/ext/spl/tests/SplFileObject/gh8561.phpt @@ -27,4 +27,4 @@ echo "After current+fgets: key=" . $file->key() . " current=" . trim($file->curr --EXPECT-- After rewind+fgets: key=1 current=line 1 After rewind+fgets+fgets: key=2 current=line 2 -After current+fgets: key=1 current=line 2 +After current+fgets: key=1 current=line 1