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)