Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions NEWS
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ PHP NEWS
. Fixed bug GH-23242 (PHP development server does not support Expect
100-continue flow control). (Sjoerd Langkemper)

- SPL:
. Fixed bug #80056 (DirectoryIterator, FilesystemIterator and
RecursiveDirectoryIterator lose directory entries on filesystems that
cannot rewind a directory handle after a partial read, e.g. 9p mounts
under WSL2 and Docker Desktop). (denkfabrik-li)


27 Aug 2026, PHP 8.6.0beta2

Expand Down
59 changes: 35 additions & 24 deletions ext/spl/spl_directory.c
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,8 @@ static zend_result spl_filesystem_object_get_file_name(spl_filesystem_object *in

static void spl_filesystem_dir_read(spl_filesystem_object *intern) /* {{{ */
{
intern->u.dir.at_initial_entry = false;

if (intern->file_name) {
/* invalidate */
zend_string_release(intern->file_name);
Expand Down Expand Up @@ -309,10 +311,39 @@ static void spl_filesystem_dir_open(spl_filesystem_object* intern, zend_string *
do {
spl_filesystem_dir_read(intern);
} while (skip_dots && spl_filesystem_is_dot(intern->u.dir.entry.d_name));
intern->u.dir.at_initial_entry = true;
}
}
/* }}} */

/* {{{ spl_filesystem_dir_rewind */
/* rewind a directory resource to its first entry */
static void spl_filesystem_dir_rewind(spl_filesystem_object *intern)
{
bool skip_dots = SPL_HAS_FLAG(intern->flags, SPL_FILE_DIR_SKIPDOTS);

intern->u.dir.index = 0;

if (intern->u.dir.at_initial_entry) {
/* No entry has been consumed since the directory was opened or last
* rewound, so the stream is still positioned at its first entry and
* there is nothing to rewind. Skipping the redundant seek matters on
* filesystems that cannot rewind a directory handle after a partial
* read (e.g. 9p): rewinddir() would silently discard the entries
* already buffered by the C library. */
return;
}

if (intern->u.dir.dirp) {
php_stream_rewinddir(intern->u.dir.dirp);
}
do {
spl_filesystem_dir_read(intern);
} while (skip_dots && spl_filesystem_is_dot(intern->u.dir.entry.d_name));
intern->u.dir.at_initial_entry = true;
}
/* }}} */

/* Can generate E_WARNINGS as we report errors from stream initialized via
* php_stream_open_wrapper_ex() */
static zend_result spl_filesystem_file_open(spl_filesystem_object *intern, bool use_include_path) /* {{{ */
Expand Down Expand Up @@ -735,9 +766,7 @@ PHP_METHOD(DirectoryIterator, rewind)
ZEND_PARSE_PARAMETERS_NONE();

CHECK_DIRECTORY_ITERATOR_IS_INITIALIZED(intern);
intern->u.dir.index = 0;
php_stream_rewinddir(intern->u.dir.dirp);
spl_filesystem_dir_read(intern);
spl_filesystem_dir_rewind(intern);
}
/* }}} */

Expand Down Expand Up @@ -1361,17 +1390,10 @@ PHP_METHOD(FilesystemIterator, __construct)
PHP_METHOD(FilesystemIterator, rewind)
{
spl_filesystem_object *intern = spl_filesystem_from_obj(Z_OBJ_P(ZEND_THIS));
bool skip_dots = SPL_HAS_FLAG(intern->flags, SPL_FILE_DIR_SKIPDOTS);

ZEND_PARSE_PARAMETERS_NONE();

intern->u.dir.index = 0;
if (intern->u.dir.dirp) {
php_stream_rewinddir(intern->u.dir.dirp);
}
do {
spl_filesystem_dir_read(intern);
} while (skip_dots && spl_filesystem_is_dot(intern->u.dir.entry.d_name));
spl_filesystem_dir_rewind(intern);
}
/* }}} */

Expand Down Expand Up @@ -1638,11 +1660,7 @@ static void spl_filesystem_dir_it_rewind(zend_object_iterator *iter)
{
spl_filesystem_object *object = spl_filesystem_iterator_to_object((spl_filesystem_iterator *)iter);

object->u.dir.index = 0;
if (object->u.dir.dirp) {
php_stream_rewinddir(object->u.dir.dirp);
}
spl_filesystem_dir_read(object);
spl_filesystem_dir_rewind(object);
}
/* }}} */

Expand Down Expand Up @@ -1726,15 +1744,8 @@ static void spl_filesystem_tree_it_rewind(zend_object_iterator *iter)
{
spl_filesystem_iterator *iterator = (spl_filesystem_iterator *)iter;
spl_filesystem_object *object = spl_filesystem_iterator_to_object(iterator);
bool skip_dots = SPL_HAS_FLAG(object->flags, SPL_FILE_DIR_SKIPDOTS);

object->u.dir.index = 0;
if (object->u.dir.dirp) {
php_stream_rewinddir(object->u.dir.dirp);
}
do {
spl_filesystem_dir_read(object);
} while (skip_dots && spl_filesystem_is_dot(object->u.dir.entry.d_name));
spl_filesystem_dir_rewind(object);
if (!Z_ISUNDEF(iterator->current)) {
zval_ptr_dtor(&iterator->current);
ZVAL_UNDEF(&iterator->current);
Expand Down
5 changes: 5 additions & 0 deletions ext/spl/spl_directory.h
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ struct _spl_filesystem_object {
php_stream *dirp;
zend_string *sub_path;
zend_long index;
/* Whether the stream is still positioned at its first (non-skipped)
* entry, in which case rewinding is a no-op and is skipped: some
* filesystems (e.g. 9p) cannot seek a directory handle after a
* partial read and would silently lose buffered entries. */
bool at_initial_entry;
zend_function *func_rewind;
zend_function *func_next;
zend_function *func_valid;
Expand Down
126 changes: 126 additions & 0 deletions ext/spl/tests/bug80056.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
--TEST--
Bug #80056 (SPL directory iterators lose entries on filesystems that cannot rewind a directory)
--FILE--
<?php
/* Simulates a filesystem (e.g. 9p as used by WSL2 and Docker Desktop) where
* seeking a directory handle after a partial read is silently ignored:
* rewinddir() reports success but the read position is not reset. */
class BrokenSeekDir {
public $context;
private $entries = ['a.txt', 'b.txt', 'c.txt', 'd.txt', 'e.txt'];
private $idx = 0;

public function dir_opendir($path, $options): bool {
$this->idx = 0;
return true;
}

public function dir_readdir(): string|false {
return $this->idx < count($this->entries) ? $this->entries[$this->idx++] : false;
}

public function dir_rewinddir(): bool {
/* Broken on purpose: pretends to succeed without resetting the position. */
return true;
}

public function dir_closedir(): bool {
return true;
}

public function url_stat($path, $flags): array|false {
return ['dev' => 0, 'ino' => 0, 'mode' => 0100644, 'nlink' => 1,
'uid' => 0, 'gid' => 0, 'rdev' => -1, 'size' => 0,
'atime' => 0, 'mtime' => 0, 'ctime' => 0, 'blksize' => -1, 'blocks' => -1];
}
}
stream_wrapper_register('brokenseek', BrokenSeekDir::class);

echo "DirectoryIterator:\n";
$names = [];
foreach (new DirectoryIterator('brokenseek://dir') as $info) {
$names[] = $info->getFilename();
}
var_dump($names);

echo "FilesystemIterator:\n";
$names = [];
$it = new FilesystemIterator('brokenseek://dir',
FilesystemIterator::KEY_AS_FILENAME | FilesystemIterator::CURRENT_AS_PATHNAME);
foreach ($it as $name => $path) {
$names[] = $name;
}
var_dump($names);

echo "RecursiveDirectoryIterator:\n";
$names = [];
$it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator('brokenseek://dir',
FilesystemIterator::KEY_AS_FILENAME | FilesystemIterator::CURRENT_AS_PATHNAME));
foreach ($it as $name => $path) {
$names[] = $name;
}
var_dump($names);

echo "Entry accessed before iteration:\n";
$it = new DirectoryIterator('brokenseek://dir');
var_dump($it->current()->getFilename());
$names = [];
foreach ($it as $info) {
$names[] = $info->getFilename();
}
var_dump($names);
?>
--EXPECT--
DirectoryIterator:
array(5) {
[0]=>
string(5) "a.txt"
[1]=>
string(5) "b.txt"
[2]=>
string(5) "c.txt"
[3]=>
string(5) "d.txt"
[4]=>
string(5) "e.txt"
}
FilesystemIterator:
array(5) {
[0]=>
string(5) "a.txt"
[1]=>
string(5) "b.txt"
[2]=>
string(5) "c.txt"
[3]=>
string(5) "d.txt"
[4]=>
string(5) "e.txt"
}
RecursiveDirectoryIterator:
array(5) {
[0]=>
string(5) "a.txt"
[1]=>
string(5) "b.txt"
[2]=>
string(5) "c.txt"
[3]=>
string(5) "d.txt"
[4]=>
string(5) "e.txt"
}
Entry accessed before iteration:
string(5) "a.txt"
array(5) {
[0]=>
string(5) "a.txt"
[1]=>
string(5) "b.txt"
[2]=>
string(5) "c.txt"
[3]=>
string(5) "d.txt"
[4]=>
string(5) "e.txt"
}
111 changes: 111 additions & 0 deletions ext/spl/tests/spl_dir_iterator_rewind_noop.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
--TEST--
SPL directory iterators only seek the directory stream when entries have been consumed
--FILE--
<?php
class LoggingDir {
public static $rewinds = 0;
public $context;
private $entries = ['a.txt', 'b.txt', 'c.txt'];
private $idx = 0;

public function dir_opendir($path, $options): bool {
$this->idx = 0;
return true;
}

public function dir_readdir(): string|false {
return $this->idx < count($this->entries) ? $this->entries[$this->idx++] : false;
}

public function dir_rewinddir(): bool {
self::$rewinds++;
$this->idx = 0;
return true;
}

public function dir_closedir(): bool {
return true;
}
}
stream_wrapper_register('logdir', LoggingDir::class);

echo "First iteration needs no seek:\n";
$it = new FilesystemIterator('logdir://dir',
FilesystemIterator::KEY_AS_FILENAME | FilesystemIterator::CURRENT_AS_PATHNAME);
$names = [];
foreach ($it as $name => $path) {
$names[] = $name;
}
var_dump($names, LoggingDir::$rewinds);

echo "Iterating again performs a real rewind:\n";
$names = [];
foreach ($it as $name => $path) {
$names[] = $name;
}
var_dump($names, LoggingDir::$rewinds);

echo "Explicit rewind after next() performs a real rewind:\n";
LoggingDir::$rewinds = 0;
$it = new DirectoryIterator('logdir://dir');
$it->next();
$it->rewind();
$names = [];
while ($it->valid()) {
$names[] = $it->getFilename();
$it->next();
}
var_dump($names, LoggingDir::$rewinds);

echo "Repeated rewind without reads stays a no-op:\n";
LoggingDir::$rewinds = 0;
$it = new DirectoryIterator('logdir://dir');
$it->rewind();
$it->rewind();
$names = [];
foreach ($it as $info) {
$names[] = $info->getFilename();
}
var_dump($names, LoggingDir::$rewinds);
?>
--EXPECT--
First iteration needs no seek:
array(3) {
[0]=>
string(5) "a.txt"
[1]=>
string(5) "b.txt"
[2]=>
string(5) "c.txt"
}
int(0)
Iterating again performs a real rewind:
array(3) {
[0]=>
string(5) "a.txt"
[1]=>
string(5) "b.txt"
[2]=>
string(5) "c.txt"
}
int(1)
Explicit rewind after next() performs a real rewind:
array(3) {
[0]=>
string(5) "a.txt"
[1]=>
string(5) "b.txt"
[2]=>
string(5) "c.txt"
}
int(1)
Repeated rewind without reads stays a no-op:
array(3) {
[0]=>
string(5) "a.txt"
[1]=>
string(5) "b.txt"
[2]=>
string(5) "c.txt"
}
int(0)
Loading