Commit d02fce5ec8d for woocommerce
commit d02fce5ec8dc1b47b4577d3b96fd2a8f2402469b
Author: Brandon Kraft <public@brandonkraft.com>
Date: Wed Aug 12 10:34:33 2026 -0500
Fix transient files directory on stream-wrapper uploads (S3-Uploads / VIP) (#67592)
* Fix transient files directory on stream-wrapper uploads
TransientFilesEngine::get_transient_files_directory() resolved the base
directory with realpath(), which cannot resolve stream wrapper paths and
returns false for them. On sites where wp_upload_dir() returns a wrapper
path -- the S3-Uploads plugin, WordPress VIP -- that false reached
untrailingslashit(), which turned it into an empty string, so
create_transient_file() built its paths from an empty base.
Detect a URL scheme on the directory and, for those paths, keep the path
verbatim and verify it with is_dir() instead of realpath(), so the
"directory doesn't exist" exception still fires for filtered paths.
Fixes #65739
* Drop esc_html from the transient files directory exception
The message is never rendered as output, and escaping it made this throw
inconsistent with the others in the same method. Suppress the phpcs sniff
with a justification instead.
* Delete expired transient files on stream-wrapper uploads
delete_expired_files() enumerated directories and files with glob(), which
does not support stream wrappers: it always goes to the local filesystem and
returns an empty array for paths like s3://bucket/uploads. The cleanup found
nothing to delete, reported deleted_count => 0, rescheduled itself for the
next day and reported success indefinitely, so expired files accumulated
with no error anywhere.
Enumerate with scandir(), which dispatches to the wrapper. The rest of the
cleanup path already worked on wrappers: wp_delete_file() calls unlink(),
and delete_directory_if_not_empty() uses FilesystemIterator and rmdir().
Also swap the URL scheme regex for wp_is_stream(), which only matches
schemes that have a wrapper registered, so a scheme-shaped path with no
wrapper keeps going through realpath() as it did before.
The tests now register a real filesystem-backed stream wrapper instead of
mocking is_dir(), since neither wp_is_stream() nor glob() behavior can be
reproduced with a mock.
diff --git a/plugins/woocommerce/changelog/65739-fix-transient-files-cleanup-stream-wrapper b/plugins/woocommerce/changelog/65739-fix-transient-files-cleanup-stream-wrapper
new file mode 100644
index 00000000000..f03addea48d
--- /dev/null
+++ b/plugins/woocommerce/changelog/65739-fix-transient-files-cleanup-stream-wrapper
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Delete expired transient files on sites whose uploads directory is a stream wrapper path, such as S3-Uploads and WordPress VIP installs, where the cleanup previously reported success without deleting anything.
diff --git a/plugins/woocommerce/changelog/65739-fix-transient-files-stream-wrapper-realpath b/plugins/woocommerce/changelog/65739-fix-transient-files-stream-wrapper-realpath
new file mode 100644
index 00000000000..258a4191d8d
--- /dev/null
+++ b/plugins/woocommerce/changelog/65739-fix-transient-files-stream-wrapper-realpath
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Keep the transient files directory working on sites whose uploads directory is a stream wrapper path, such as S3-Uploads and WordPress VIP installs.
diff --git a/plugins/woocommerce/src/Internal/TransientFiles/TransientFilesEngine.php b/plugins/woocommerce/src/Internal/TransientFiles/TransientFilesEngine.php
index 723836a098b..5e48df2af7d 100644
--- a/plugins/woocommerce/src/Internal/TransientFiles/TransientFilesEngine.php
+++ b/plugins/woocommerce/src/Internal/TransientFiles/TransientFilesEngine.php
@@ -38,6 +38,12 @@ class TransientFilesEngine implements RegisterHooksInterface {
private const CLEANUP_ACTION_NAME = 'woocommerce_expired_transient_files_cleanup';
private const CLEANUP_ACTION_GROUP = 'wc_batch_processes';
+ /**
+ * Regular expression matching the name of a directory that holds the files expiring on a given date.
+ * Equivalent to the "[2-9][0-9][0-9][0-9]-[01][0-9]-[0-3][0-9]" glob pattern used previously.
+ */
+ private const EXPIRATION_DATE_DIRECTORY_REGEX = '/^[2-9]\d{3}-[01]\d-[0-3]\d$/';
+
/**
* The instance of LegacyProxy to use.
*
@@ -101,8 +107,8 @@ class TransientFilesEngine implements RegisterHooksInterface {
*/
$transient_files_directory = apply_filters( 'woocommerce_transient_files_directory', $default_transient_files_directory );
- $realpathed_transient_files_directory = $this->legacy_proxy->call_function( 'realpath', $transient_files_directory );
- if ( false === $realpathed_transient_files_directory ) {
+ $resolved_transient_files_directory = $this->resolve_directory_if_it_exists( $transient_files_directory );
+ if ( false === $resolved_transient_files_directory ) {
if ( $transient_files_directory === $default_transient_files_directory ) {
if ( ! $this->legacy_proxy->call_function( 'wp_mkdir_p', $transient_files_directory ) ) {
throw new Exception( "Can't create directory: $transient_files_directory" );
@@ -114,13 +120,39 @@ class TransientFilesEngine implements RegisterHooksInterface {
$wp_filesystem->put_contents( $transient_files_directory . '/.htaccess', 'deny from all' );
$wp_filesystem->put_contents( $transient_files_directory . '/index.html', '' );
- $realpathed_transient_files_directory = $this->legacy_proxy->call_function( 'realpath', $transient_files_directory );
+ $resolved_transient_files_directory = $this->resolve_directory_if_it_exists( $transient_files_directory );
+ if ( false === $resolved_transient_files_directory ) {
+ // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Not rendered as output, and consistent with the other throws in this method.
+ throw new Exception( "The directory was created but can't be resolved: $transient_files_directory" );
+ }
} else {
throw new Exception( "The base transient files directory doesn't exist: $transient_files_directory" );
}
}
- return untrailingslashit( $realpathed_transient_files_directory );
+ return untrailingslashit( $resolved_transient_files_directory );
+ }
+
+ /**
+ * Get the canonical path of a directory, if the directory exists.
+ *
+ * Paths handled by a stream wrapper can't be resolved with realpath, which returns false for them and would
+ * turn a perfectly valid directory into an empty string. Sites that store uploads through a stream wrapper
+ * (the S3-Uploads plugin and WordPress VIP, where wp_upload_dir returns something like "s3://bucket/uploads")
+ * hit that case, so for those the path is kept verbatim and only its existence is verified.
+ *
+ * wp_is_stream only recognizes schemes that have a wrapper actually registered, so a path that merely looks
+ * like a URL still goes through realpath, as it did before.
+ *
+ * @param string $directory The directory to resolve.
+ * @return string|false The canonical path of the directory, or false if the directory doesn't exist.
+ */
+ private function resolve_directory_if_it_exists( string $directory ) {
+ if ( wp_is_stream( $directory ) ) {
+ return $this->legacy_proxy->call_function( 'is_dir', $directory ) ? $directory : false;
+ }
+
+ return $this->legacy_proxy->call_function( 'realpath', $directory );
}
/**
@@ -267,25 +299,52 @@ class TransientFilesEngine implements RegisterHooksInterface {
*
* @param int $limit Maximum number of files to delete.
* @return array "deleted_count" with the number of files actually deleted, "files_remain" that will be true if there are still files left to delete.
- * @throws Exception The base directory for transient files (possibly changed via filter) doesn't exist.
+ * @throws Exception The base directory for transient files (possibly changed via filter) doesn't exist, or its contents can't be listed.
*/
public function delete_expired_files( int $limit = 1000 ): array {
$expiration_date_gmt = $this->legacy_proxy->call_function( 'gmdate', 'Y-m-d' );
$base_dir = $this->get_transient_files_directory();
- $subdirs = glob( $base_dir . '/[2-9][0-9][0-9][0-9]-[01][0-9]-[0-3][0-9]', GLOB_ONLYDIR );
- if ( false === $subdirs ) {
+
+ /*
+ * scandir, not glob: glob doesn't support stream wrappers (it always goes to the local filesystem)
+ * and returns an empty array for paths like "s3://bucket/uploads", which would silently turn the
+ * cleanup into a no-op on those sites. scandir returns bare names rather than full paths.
+ */
+ $entries = scandir( $base_dir );
+ if ( false === $entries ) {
+ // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Not rendered as output, and consistent with the other throws in this class.
throw new Exception( "Error when getting the list of subdirectories of $base_dir" );
}
- $subdirs = array_map( fn( $name ) => substr( $name, strlen( $name ) - 10, 10 ), $subdirs );
+ $subdirs = array_values(
+ array_filter(
+ $entries,
+ fn( $name ) => 1 === preg_match( self::EXPIRATION_DATE_DIRECTORY_REGEX, $name ) && is_dir( $base_dir . '/' . $name )
+ )
+ );
+
$expired_subdirs = array_filter( $subdirs, fn( $name ) => $name < $expiration_date_gmt );
asort( $subdirs ); // We want to delete files starting with the oldest expiration month.
$remaining_limit = $limit;
$limit_reached = false;
foreach ( $expired_subdirs as $subdir ) {
- $full_dir_path = $base_dir . '/' . $subdir;
- $files_to_delete = glob( $full_dir_path . '/*' );
+ $full_dir_path = $base_dir . '/' . $subdir;
+
+ $dir_entries = scandir( $full_dir_path );
+ if ( false === $dir_entries ) {
+ // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Not rendered as output, and consistent with the other throws in this class.
+ throw new Exception( "Error when getting the list of files in $full_dir_path" );
+ }
+
+ $files_to_delete = array_values(
+ array_map(
+ fn( $name ) => $full_dir_path . '/' . $name,
+ // Skip dot files, matching what the "*" glob pattern used to do. This also drops "." and "..".
+ array_filter( $dir_entries, fn( $name ) => '.' !== $name[0] )
+ )
+ );
+
if ( count( $files_to_delete ) > $remaining_limit ) {
$limit_reached = true;
$files_to_delete = array_slice( $files_to_delete, 0, $remaining_limit );
diff --git a/plugins/woocommerce/tests/php/src/Internal/TransientFiles/TransientFilesEngineTest.php b/plugins/woocommerce/tests/php/src/Internal/TransientFiles/TransientFilesEngineTest.php
index 026745552d0..839531bdd9b 100644
--- a/plugins/woocommerce/tests/php/src/Internal/TransientFiles/TransientFilesEngineTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/TransientFiles/TransientFilesEngineTest.php
@@ -30,6 +30,18 @@ class TransientFilesEngineTest extends \WC_REST_Unit_Test_Case {
*/
protected static string $transient_files_dir;
+ /**
+ * The scheme registered by the stream wrapper used to simulate S3-Uploads/VIP style uploads directories.
+ */
+ private const STREAM_SCHEME = 'wctransienttest';
+
+ /**
+ * The local directory that the test stream wrapper maps its paths onto.
+ *
+ * @var string
+ */
+ private string $stream_root;
+
/**
* Runs before each test.
*/
@@ -38,10 +50,44 @@ class TransientFilesEngineTest extends \WC_REST_Unit_Test_Case {
$this->reset_container_resolutions();
self::rmdir_recursive( self::$transient_files_dir, false );
+
+ $this->stream_root = sys_get_temp_dir() . '/wc-stream-uploads-' . wp_generate_uuid4();
+ wp_mkdir_p( $this->stream_root . '/uploads' );
+ TransientFilesTestStreamWrapper::register( self::STREAM_SCHEME, $this->stream_root );
+
$this->sut = $this->get_instance_of( TransientFilesEngine::class );
$this->sut->register();
}
+ /**
+ * Runs after each test.
+ */
+ public function tearDown(): void {
+ TransientFilesTestStreamWrapper::unregister( self::STREAM_SCHEME );
+ self::rmdir_recursive( $this->stream_root, true );
+
+ parent::tearDown();
+ }
+
+ /**
+ * Get the wrapper path of the uploads directory served by the test stream wrapper.
+ *
+ * @return string The wrapper path, e.g. "wctransienttest://uploads".
+ */
+ private function stream_uploads_dir(): string {
+ return self::STREAM_SCHEME . '://uploads';
+ }
+
+ /**
+ * Get the local path that a wrapper path maps onto, for asserting against the real filesystem.
+ *
+ * @param string $relative_path Path relative to the wrapper root, without a leading slash.
+ * @return string The equivalent local path.
+ */
+ private function local_path_for( string $relative_path ): string {
+ return $this->stream_root . '/' . $relative_path;
+ }
+
/**
* Runs before all the tests in the class.
*/
@@ -272,6 +318,165 @@ class TransientFilesEngineTest extends \WC_REST_Unit_Test_Case {
}
}
+ /**
+ * @testdox get_transient_files_directory keeps the scheme of stream wrapper uploads directories (S3-Uploads, VIP).
+ */
+ public function test_get_transient_files_directory_preserves_stream_wrapper_scheme() {
+ $this->register_legacy_proxy_function_mocks(
+ array(
+ 'wp_upload_dir' => fn() => array( 'basedir' => $this->stream_uploads_dir() ),
+ )
+ );
+
+ $result = $this->sut->get_transient_files_directory();
+
+ $this->assertEquals( $this->stream_uploads_dir() . '/woocommerce_transient_files', $result );
+ $this->assertDirectoryExists( $this->local_path_for( 'uploads/woocommerce_transient_files' ) );
+ $this->assertFalse( realpath( $result ), 'realpath is expected to fail on wrapper paths; that is the bug being guarded against' );
+ }
+
+ /**
+ * @testdox get_transient_files_directory uses realpath for scheme-shaped paths that have no wrapper registered.
+ */
+ public function test_get_transient_files_directory_uses_realpath_when_no_wrapper_is_registered() {
+ $this->register_legacy_proxy_function_mocks(
+ array(
+ 'wp_upload_dir' => fn() => array( 'basedir' => 'notregistered://bucket/uploads' ),
+ 'realpath' => fn( $path ) => '/real/' . $path,
+ )
+ );
+
+ $result = $this->sut->get_transient_files_directory();
+
+ $this->assertEquals( '/real/notregistered://bucket/uploads/woocommerce_transient_files', $result );
+ }
+
+ /**
+ * @testdox get_transient_files_directory throws if a stream wrapper directory supplied via hook doesn't exist.
+ */
+ public function test_get_transient_files_directory_throws_if_stream_wrapper_directory_does_not_exist() {
+ $missing_dir = self::STREAM_SCHEME . '://custom-dir';
+ add_filter( 'woocommerce_transient_files_directory', fn() => $missing_dir );
+
+ $this->register_legacy_proxy_function_mocks(
+ array(
+ 'wp_upload_dir' => fn() => array( 'basedir' => $this->stream_uploads_dir() ),
+ )
+ );
+
+ $this->expectException( \Exception::class );
+ $this->expectExceptionMessage( "The base transient files directory doesn't exist: $missing_dir" );
+
+ try {
+ $this->sut->get_transient_files_directory();
+ } finally {
+ remove_all_filters( 'woocommerce_transient_files_directory' );
+ }
+ }
+
+ /**
+ * @testdox get_transient_files_directory throws if the created directory still can't be resolved.
+ */
+ public function test_get_transient_files_directory_throws_if_created_directory_cannot_be_resolved() {
+ $this->register_legacy_proxy_function_mocks(
+ array(
+ 'wp_upload_dir' => fn() => array( 'basedir' => '/wordpress/uploads' ),
+ 'realpath' => fn() => false,
+ 'wp_mkdir_p' => fn() => true,
+ )
+ );
+
+ $this->expectException( \Exception::class );
+ $this->expectExceptionMessage( "The directory was created but can't be resolved: /wordpress/uploads/woocommerce_transient_files" );
+
+ $this->sut->get_transient_files_directory();
+ }
+
+ /**
+ * @testdox create_transient_file writes the file inside the uploads directory on stream wrapper uploads directories.
+ */
+ public function test_create_transient_file_writes_inside_stream_wrapper_uploads_directory() {
+ $this->register_legacy_proxy_function_mocks(
+ array(
+ 'wp_upload_dir' => fn() => array( 'basedir' => $this->stream_uploads_dir() ),
+ 'random_bytes' => fn() => implode( array_map( 'chr', array( 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15 ) ) ),
+ 'gmdate' => fn( $format, $date = null ) =>
+ is_null( $date ) && 'Y-m-d' === $format ? '2023-12-01' : gmdate( $format, $date ),
+ )
+ );
+
+ $result = $this->sut->create_transient_file( 'foobar', '2023-12-02' );
+
+ $this->assertEquals( '7e7c02000102030405060708090a0b0c0d0e0f', $result );
+
+ $expected_wrapper_path = $this->stream_uploads_dir() . '/woocommerce_transient_files/2023-12-02/000102030405060708090a0b0c0d0e0f';
+ $this->assertEquals( $expected_wrapper_path, $this->sut->get_transient_file_path( $result ) );
+
+ $local_path = $this->local_path_for( 'uploads/woocommerce_transient_files/2023-12-02/000102030405060708090a0b0c0d0e0f' );
+ $this->assertFileExists( $local_path );
+ $this->assertEquals( 'foobar', file_get_contents( $local_path ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
+ }
+
+ /**
+ * @testdox delete_expired_files deletes expired files on stream wrapper uploads directories.
+ */
+ public function test_delete_expired_files_works_on_stream_wrapper_uploads_directory() {
+ $today = '2023-12-01';
+
+ $this->register_legacy_proxy_function_mocks(
+ array(
+ 'wp_upload_dir' => fn() => array( 'basedir' => $this->stream_uploads_dir() ),
+ 'gmdate' => function ( $format, $date = null ) use ( &$today ) {
+ return is_null( $date ) && 'Y-m-d' === $format ? $today : gmdate( $format, $date );
+ },
+ )
+ );
+
+ $expired_file = $this->sut->create_transient_file( 'expired', '2023-12-01' );
+ $not_expired_file = $this->sut->create_transient_file( 'not expired', '2023-12-31' );
+
+ $today = '2023-12-15';
+
+ $result = $this->sut->delete_expired_files();
+
+ $this->assertEquals( 1, $result['deleted_count'], 'The expired file should have been deleted' );
+ $this->assertFalse( $result['files_remain'] );
+ $this->assertNull( $this->sut->get_transient_file_path( $expired_file ) );
+ $this->assertNotNull( $this->sut->get_transient_file_path( $not_expired_file ) );
+ $this->assertDirectoryDoesNotExist( $this->local_path_for( 'uploads/woocommerce_transient_files/2023-12-01' ) );
+ $this->assertDirectoryExists( $this->local_path_for( 'uploads/woocommerce_transient_files/2023-12-31' ) );
+ }
+
+ /**
+ * @testdox delete_expired_files ignores directories that aren't named after an expiration date.
+ */
+ public function test_delete_expired_files_ignores_non_date_directories_on_stream_wrapper() {
+ $today = '2023-12-01';
+
+ $this->register_legacy_proxy_function_mocks(
+ array(
+ 'wp_upload_dir' => fn() => array( 'basedir' => $this->stream_uploads_dir() ),
+ 'gmdate' => function ( $format, $date = null ) use ( &$today ) {
+ return is_null( $date ) && 'Y-m-d' === $format ? $today : gmdate( $format, $date );
+ },
+ )
+ );
+
+ $this->sut->create_transient_file( 'expired', '2023-12-01' );
+
+ $base_dir = $this->local_path_for( 'uploads/woocommerce_transient_files' );
+ wp_mkdir_p( $base_dir . '/not-a-date' );
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Direct write is fine in a test fixture.
+ file_put_contents( $base_dir . '/not-a-date/keepme', 'keep' );
+
+ $today = '2023-12-15';
+
+ $result = $this->sut->delete_expired_files();
+
+ $this->assertEquals( 1, $result['deleted_count'] );
+ $this->assertFileExists( $base_dir . '/not-a-date/keepme' );
+ }
+
/**
* @testdox get_transient_file_path returns null for a file that doesn't exist, including wrongly formatted names.
*
diff --git a/plugins/woocommerce/tests/php/src/Internal/TransientFiles/TransientFilesTestStreamWrapper.php b/plugins/woocommerce/tests/php/src/Internal/TransientFiles/TransientFilesTestStreamWrapper.php
new file mode 100644
index 00000000000..2afca8ae07a
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/TransientFiles/TransientFilesTestStreamWrapper.php
@@ -0,0 +1,311 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\TransientFiles;
+
+/**
+ * A stream wrapper backed by a local directory, for testing code that runs on installs where the
+ * uploads directory is a stream wrapper path (the S3-Uploads plugin, WordPress VIP).
+ *
+ * Registering a real wrapper rather than mocking filesystem functions is what makes these tests
+ * meaningful: wp_is_stream only recognizes schemes that have a wrapper actually registered, and
+ * glob() fails on wrapper paths in a way no mock reproduces.
+ *
+ * Note that stream_metadata must be implemented even though it does nothing here, because
+ * WP_Filesystem_Direct::put_contents() calls chmod() on every file it writes.
+ */
+class TransientFilesTestStreamWrapper {
+
+ /**
+ * The stream context, set by PHP. Unused, but the wrapper protocol requires the property to exist.
+ *
+ * @var resource|null
+ */
+ public $context;
+
+ /**
+ * Local directory that the wrapper maps paths onto.
+ *
+ * @var string
+ */
+ private static string $root = '';
+
+ /**
+ * The scheme this wrapper is registered under, including the "://" separator.
+ *
+ * @var string
+ */
+ private static string $scheme = '';
+
+ /**
+ * The directory handle currently open. PHP only calls the dir_* methods after a successful dir_opendir.
+ *
+ * @var resource
+ */
+ private $dir_handle;
+
+ /**
+ * The file handle currently open. PHP only calls the stream_* methods after a successful stream_open.
+ *
+ * @var resource
+ */
+ private $file_handle;
+
+ /**
+ * Register the wrapper and point it at a local directory.
+ *
+ * @param string $scheme The scheme to register, without the "://" separator.
+ * @param string $root The local directory to map wrapper paths onto.
+ */
+ public static function register( string $scheme, string $root ): void {
+ self::$scheme = $scheme . '://';
+ self::$root = untrailingslashit( $root );
+
+ if ( in_array( $scheme, stream_get_wrappers(), true ) ) {
+ stream_wrapper_unregister( $scheme );
+ }
+
+ stream_wrapper_register( $scheme, self::class );
+ }
+
+ /**
+ * Unregister the wrapper.
+ *
+ * @param string $scheme The scheme to unregister, without the "://" separator.
+ */
+ public static function unregister( string $scheme ): void {
+ if ( in_array( $scheme, stream_get_wrappers(), true ) ) {
+ stream_wrapper_unregister( $scheme );
+ }
+
+ self::$scheme = '';
+ self::$root = '';
+ }
+
+ /**
+ * Translate a wrapper path into the local path it maps onto.
+ *
+ * @param string $path The wrapper path.
+ * @return string The equivalent local path.
+ */
+ private function local_path( string $path ): string {
+ return self::$root . '/' . ltrim( substr( $path, strlen( self::$scheme ) ), '/' );
+ }
+
+ /**
+ * Open a directory.
+ *
+ * @param string $path The directory to open.
+ * @param int $options Options, unused.
+ * @return bool True if the directory was opened.
+ */
+ public function dir_opendir( string $path, int $options ): bool {
+ unset( $options );
+
+ // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- A missing directory is a valid outcome here.
+ $handle = @opendir( $this->local_path( $path ) );
+ if ( false === $handle ) {
+ return false;
+ }
+
+ $this->dir_handle = $handle;
+ return true;
+ }
+
+ /**
+ * Read the next entry from the open directory.
+ *
+ * @return string|false The entry name, or false when there are no more entries.
+ */
+ public function dir_readdir() {
+ return readdir( $this->dir_handle );
+ }
+
+ /**
+ * Close the open directory.
+ *
+ * @return bool Always true.
+ */
+ public function dir_closedir(): bool {
+ closedir( $this->dir_handle );
+ return true;
+ }
+
+ /**
+ * Rewind the open directory.
+ *
+ * @return bool Always true.
+ */
+ public function dir_rewinddir(): bool {
+ rewinddir( $this->dir_handle );
+ return true;
+ }
+
+ /**
+ * Get information about a path.
+ *
+ * @param string $path The path to stat.
+ * @param int $flags Flags, unused.
+ * @return array|false The stat information, or false if the path doesn't exist.
+ */
+ public function url_stat( string $path, int $flags ) {
+ unset( $flags );
+
+ // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- A missing path is a valid outcome here.
+ return @stat( $this->local_path( $path ) );
+ }
+
+ /**
+ * Open a file.
+ *
+ * @param string $path The file to open.
+ * @param string $mode The mode to open the file with.
+ * @param int $options Options, unused.
+ * @param string $opened_path Set to the opened path, unused.
+ * @return bool True if the file was opened.
+ */
+ public function stream_open( string $path, string $mode, int $options, &$opened_path ): bool {
+ unset( $options, $opened_path );
+
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen, WordPress.PHP.NoSilencedErrors.Discouraged -- This is the wrapper backing the filesystem, it can't route through WP_Filesystem.
+ $handle = @fopen( $this->local_path( $path ), $mode );
+ if ( false === $handle ) {
+ return false;
+ }
+
+ $this->file_handle = $handle;
+ return true;
+ }
+
+ /**
+ * Read from the open file.
+ *
+ * @param int $count Number of bytes to read.
+ * @return string|false The bytes read.
+ */
+ public function stream_read( int $count ) {
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- This is the wrapper backing the filesystem.
+ return fread( $this->file_handle, $count );
+ }
+
+ /**
+ * Write to the open file.
+ *
+ * @param string $data The data to write.
+ * @return int The number of bytes written.
+ */
+ public function stream_write( string $data ): int {
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- This is the wrapper backing the filesystem.
+ return (int) fwrite( $this->file_handle, $data );
+ }
+
+ /**
+ * Is the end of the open file reached?
+ *
+ * @return bool True if the end of the file is reached.
+ */
+ public function stream_eof(): bool {
+ return feof( $this->file_handle );
+ }
+
+ /**
+ * Get information about the open file.
+ *
+ * @return array|false The stat information.
+ */
+ public function stream_stat() {
+ return fstat( $this->file_handle );
+ }
+
+ /**
+ * Get the current position in the open file.
+ *
+ * @return int The current position.
+ */
+ public function stream_tell(): int {
+ return (int) ftell( $this->file_handle );
+ }
+
+ /**
+ * Move the position in the open file.
+ *
+ * @param int $offset The offset to move to.
+ * @param int $whence How the offset is interpreted.
+ * @return bool True if the position was changed.
+ */
+ public function stream_seek( int $offset, int $whence = SEEK_SET ): bool {
+ return 0 === fseek( $this->file_handle, $offset, $whence );
+ }
+
+ /**
+ * Flush the open file.
+ *
+ * @return bool True if the flush succeeded.
+ */
+ public function stream_flush(): bool {
+ return fflush( $this->file_handle );
+ }
+
+ /**
+ * Close the open file.
+ *
+ * @return bool Always true.
+ */
+ public function stream_close(): bool {
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- This is the wrapper backing the filesystem.
+ fclose( $this->file_handle );
+ return true;
+ }
+
+ /**
+ * Set metadata on a path. Does nothing, but must exist: WP_Filesystem_Direct::put_contents()
+ * calls chmod() on every file it writes, and without this the call raises a PHP warning.
+ *
+ * @param string $path The path to act on.
+ * @param int $option The metadata to set.
+ * @param mixed $value The value to set.
+ * @return bool Always true.
+ */
+ public function stream_metadata( string $path, int $option, $value ): bool {
+ unset( $path, $option, $value );
+ return true;
+ }
+
+ /**
+ * Delete a file.
+ *
+ * @param string $path The file to delete.
+ * @return bool True if the file was deleted.
+ */
+ public function unlink( string $path ): bool {
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- This is the wrapper backing the filesystem.
+ return @unlink( $this->local_path( $path ) );
+ }
+
+ /**
+ * Create a directory.
+ *
+ * @param string $path The directory to create.
+ * @param int $mode The permissions for the new directory.
+ * @param int $options Options, the recursive flag is honored.
+ * @return bool True if the directory was created.
+ */
+ public function mkdir( string $path, int $mode, int $options ): bool {
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir, WordPress.PHP.NoSilencedErrors.Discouraged
+ return @mkdir( $this->local_path( $path ), $mode, (bool) ( $options & STREAM_MKDIR_RECURSIVE ) );
+ }
+
+ /**
+ * Delete a directory.
+ *
+ * @param string $path The directory to delete.
+ * @param int $options Options, unused.
+ * @return bool True if the directory was deleted.
+ */
+ public function rmdir( string $path, int $options ): bool {
+ unset( $options );
+
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir, WordPress.PHP.NoSilencedErrors.Discouraged -- This is the wrapper backing the filesystem.
+ return @rmdir( $this->local_path( $path ) );
+ }
+}