From ffb35f94ad1af2aa9a61981332226799d333e3dc Mon Sep 17 00:00:00 2001 From: denkfabrik-li <274324701+denkfabrik-li@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:06:48 +0200 Subject: [PATCH] Fix bug #80056: SPL directory iterators lose entries on 9p filesystems FilesystemIterator, RecursiveDirectoryIterator and DirectoryIterator read the first directory entry eagerly in their constructor. The implicit rewind() at the start of the first foreach then called rewinddir() on a stream whose position was already past the first entry. On filesystems that cannot seek a directory handle after a partial read - most notably 9p mounts as used by WSL2 and Docker Desktop on Windows - that seek is silently ignored and the entries already buffered by the C library are lost: an entire libc getdents buffer (about 21 entries with musl, about 343 with glibc) disappears from the iteration, while scandir(), glob() and plain readdir() see the full listing. Track whether the directory stream is still positioned at its first entry; rewinding is a no-op in that state. The constructor still opens the directory eagerly (an invalid path keeps throwing UnexpectedValueException) and still pre-reads the first entry, so the observable object state after construction is unchanged. A rewind after any entry has been consumed keeps performing a real rewinddir() as before. --- NEWS | 6 + ext/spl/spl_directory.c | 59 ++++---- ext/spl/spl_directory.h | 5 + ext/spl/tests/bug80056.phpt | 126 ++++++++++++++++++ .../tests/spl_dir_iterator_rewind_noop.phpt | 111 +++++++++++++++ 5 files changed, 283 insertions(+), 24 deletions(-) create mode 100644 ext/spl/tests/bug80056.phpt create mode 100644 ext/spl/tests/spl_dir_iterator_rewind_noop.phpt diff --git a/NEWS b/NEWS index 5276069926e5..872142ac7ef1 100644 --- a/NEWS +++ b/NEWS @@ -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 diff --git a/ext/spl/spl_directory.c b/ext/spl/spl_directory.c index daad1a4908f2..65ddf315827f 100644 --- a/ext/spl/spl_directory.c +++ b/ext/spl/spl_directory.c @@ -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); @@ -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) /* {{{ */ @@ -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); } /* }}} */ @@ -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); } /* }}} */ @@ -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); } /* }}} */ @@ -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); diff --git a/ext/spl/spl_directory.h b/ext/spl/spl_directory.h index 6d940e8aa8ef..1e5c5e8e1a48 100644 --- a/ext/spl/spl_directory.h +++ b/ext/spl/spl_directory.h @@ -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; diff --git a/ext/spl/tests/bug80056.phpt b/ext/spl/tests/bug80056.phpt new file mode 100644 index 000000000000..bbb05268664f --- /dev/null +++ b/ext/spl/tests/bug80056.phpt @@ -0,0 +1,126 @@ +--TEST-- +Bug #80056 (SPL directory iterators lose entries on filesystems that cannot rewind a directory) +--FILE-- +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" +} diff --git a/ext/spl/tests/spl_dir_iterator_rewind_noop.phpt b/ext/spl/tests/spl_dir_iterator_rewind_noop.phpt new file mode 100644 index 000000000000..b5c7609da969 --- /dev/null +++ b/ext/spl/tests/spl_dir_iterator_rewind_noop.phpt @@ -0,0 +1,111 @@ +--TEST-- +SPL directory iterators only seek the directory stream when entries have been consumed +--FILE-- +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)