From c7227072b653a2356057ce531169f9b9affba025 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20W=C3=BCnsch?= Date: Fri, 17 Apr 2026 10:30:15 +0200 Subject: [PATCH 01/41] Filesystem: Update constructor parameter default value to an empty array --- src/wp-admin/includes/class-wp-filesystem-ftpext.php | 2 +- src/wp-admin/includes/class-wp-filesystem-ssh2.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wp-admin/includes/class-wp-filesystem-ftpext.php b/src/wp-admin/includes/class-wp-filesystem-ftpext.php index 0ab4bc17c32a2..f640debc3044a 100644 --- a/src/wp-admin/includes/class-wp-filesystem-ftpext.php +++ b/src/wp-admin/includes/class-wp-filesystem-ftpext.php @@ -28,7 +28,7 @@ class WP_Filesystem_FTPext extends WP_Filesystem_Base { * * @param array $opt */ - public function __construct( $opt = '' ) { + public function __construct( $opt = array() ) { $this->method = 'ftpext'; $this->errors = new WP_Error(); diff --git a/src/wp-admin/includes/class-wp-filesystem-ssh2.php b/src/wp-admin/includes/class-wp-filesystem-ssh2.php index 9146045025942..a26d4f7c6b41e 100644 --- a/src/wp-admin/includes/class-wp-filesystem-ssh2.php +++ b/src/wp-admin/includes/class-wp-filesystem-ssh2.php @@ -60,7 +60,7 @@ class WP_Filesystem_SSH2 extends WP_Filesystem_Base { * * @param array $opt */ - public function __construct( $opt = '' ) { + public function __construct( $opt = array() ) { $this->method = 'ssh2'; $this->errors = new WP_Error(); From e89319245efa4819e1703ebd4d15aa1b37a8d21a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20W=C3=BCnsch?= Date: Thu, 4 Jun 2026 11:54:00 +0200 Subject: [PATCH 02/41] Add docs --- src/wp-admin/includes/class-wp-filesystem-ftpext.php | 10 +++++++++- src/wp-admin/includes/class-wp-filesystem-ssh2.php | 12 +++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/wp-admin/includes/class-wp-filesystem-ftpext.php b/src/wp-admin/includes/class-wp-filesystem-ftpext.php index f640debc3044a..a4d53455b9dfa 100644 --- a/src/wp-admin/includes/class-wp-filesystem-ftpext.php +++ b/src/wp-admin/includes/class-wp-filesystem-ftpext.php @@ -26,7 +26,15 @@ class WP_Filesystem_FTPext extends WP_Filesystem_Base { * * @since 2.5.0 * - * @param array $opt + * @param array $opt { + * Optional. Array of connection options. + * + * @type string $hostname Required. FTP server hostname. + * @type string $username Required. FTP username. + * @type string $password Required. FTP password. + * @type int $port Optional. FTP server port. Default 21. + * @type string $connection_type Optional. Connection type. Use 'ftps' to enable SSL. + * } */ public function __construct( $opt = array() ) { $this->method = 'ftpext'; diff --git a/src/wp-admin/includes/class-wp-filesystem-ssh2.php b/src/wp-admin/includes/class-wp-filesystem-ssh2.php index a26d4f7c6b41e..32a3793335f39 100644 --- a/src/wp-admin/includes/class-wp-filesystem-ssh2.php +++ b/src/wp-admin/includes/class-wp-filesystem-ssh2.php @@ -58,7 +58,17 @@ class WP_Filesystem_SSH2 extends WP_Filesystem_Base { * * @since 2.7.0 * - * @param array $opt + * @param array $opt { + * Optional. Array of connection options. + * + * @type string $hostname Required. SSH server hostname. + * @type int $port Optional. SSH server port. Default 22. + * @type string $username Optional. SSH username. Required when not using keys. + * @type string $password Optional. SSH password. May be empty when using keys. + * @type string $public_key Optional. Path to public key file for publickey authentication. + * @type string $private_key Optional. Path to private key file for publickey authentication. + * @type array $hostkey Optional. Hostkey options passed to ssh2_connect. + * } */ public function __construct( $opt = array() ) { $this->method = 'ssh2'; From 95c277634efb3d9fae59582abb174e347cf8bd6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20W=C3=BCnsch?= Date: Thu, 4 Jun 2026 15:06:01 +0200 Subject: [PATCH 03/41] Add WP_Filesystem_ftpsockets --- .../includes/class-wp-filesystem-ftpsockets.php | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php b/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php index cc665ad9bf7b4..05a1580eb9475 100644 --- a/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php +++ b/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php @@ -26,9 +26,16 @@ class WP_Filesystem_ftpsockets extends WP_Filesystem_Base { * * @since 2.5.0 * - * @param array $opt + * @param array $opt { + * * Optional. Array of connection options. + * * + * * @type string $hostname Required. FTP server hostname. + * * @type string $username Required. FTP username. + * * @type string $password Required. FTP password. + * * @type int $port Optional. FTP server port. Default 21. + * * } */ - public function __construct( $opt = '' ) { + public function __construct( $opt = array() ) { $this->method = 'ftpsockets'; $this->errors = new WP_Error(); From 8fc10a727b8545bc06069d9fad1f67a365e7bf0b Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 8 Jun 2026 23:49:06 -0700 Subject: [PATCH 04/41] Fix PHPStan issues up to rule level 5 (except for FTP type) --- composer.json | 3 +- .../includes/class-wp-filesystem-base.php | 7 +- .../includes/class-wp-filesystem-direct.php | 5 +- .../includes/class-wp-filesystem-ftpext.php | 62 ++++++++++++++--- .../class-wp-filesystem-ftpsockets.php | 43 ++++++++++-- .../includes/class-wp-filesystem-ssh2.php | 66 ++++++++++++++----- 6 files changed, 148 insertions(+), 38 deletions(-) diff --git a/composer.json b/composer.json index aee7a09524994..7c99860b9b347 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,8 @@ "php": ">=7.4" }, "suggest": { - "ext-dom": "*" + "ext-dom": "*", + "ext-ftp": "*" }, "require-dev": { "composer/ca-bundle": "1.5.11", diff --git a/src/wp-admin/includes/class-wp-filesystem-base.php b/src/wp-admin/includes/class-wp-filesystem-base.php index 125c2d3b9a8b0..e89dc20eb8f76 100644 --- a/src/wp-admin/includes/class-wp-filesystem-base.php +++ b/src/wp-admin/includes/class-wp-filesystem-base.php @@ -44,6 +44,7 @@ class WP_Filesystem_Base { public $errors = null; /** + * @var array */ public $options = array(); @@ -440,9 +441,9 @@ public function getnumchmodfromh( $mode ) { $mode = strtr( $mode, $trans ); $newmode = $mode[0]; - $newmode .= $mode[1] + $mode[2] + $mode[3]; - $newmode .= $mode[4] + $mode[5] + $mode[6]; - $newmode .= $mode[7] + $mode[8] + $mode[9]; + $newmode .= (int) $mode[1] + (int) $mode[2] + (int) $mode[3]; + $newmode .= (int) $mode[4] + (int) $mode[5] + (int) $mode[6]; + $newmode .= (int) $mode[7] + (int) $mode[8] + (int) $mode[9]; return $newmode; } diff --git a/src/wp-admin/includes/class-wp-filesystem-direct.php b/src/wp-admin/includes/class-wp-filesystem-direct.php index a4b197c15229f..c749b345b0730 100644 --- a/src/wp-admin/includes/class-wp-filesystem-direct.php +++ b/src/wp-admin/includes/class-wp-filesystem-direct.php @@ -23,6 +23,7 @@ class WP_Filesystem_Direct extends WP_Filesystem_Base { * @param mixed $arg Not used. */ public function __construct( $arg ) { + unset( $arg ); $this->method = 'direct'; $this->errors = new WP_Error(); } @@ -240,7 +241,7 @@ public function chown( $file, $owner, $recursive = false ) { * @since 2.5.0 * * @param string $file Path to the file. - * @return string|false Username of the owner on success, false on failure. + * @return string|int<1, max>|false Username of the owner on success, or UID of file owner if not available; false on failure. */ public function owner( $file ) { $owneruid = @fileowner( $file ); @@ -285,7 +286,7 @@ public function getchmod( $file ) { * @since 2.5.0 * * @param string $file Path to the file. - * @return string|false The group on success, false on failure. + * @return string|int<1, max>|false Username of the owner on success, or group ID of file owner if not available; false on failure. */ public function group( $file ) { $gid = @filegroup( $file ); diff --git a/src/wp-admin/includes/class-wp-filesystem-ftpext.php b/src/wp-admin/includes/class-wp-filesystem-ftpext.php index a4d53455b9dfa..e2b8b5ba9e04a 100644 --- a/src/wp-admin/includes/class-wp-filesystem-ftpext.php +++ b/src/wp-admin/includes/class-wp-filesystem-ftpext.php @@ -12,6 +12,14 @@ * @since 2.5.0 * * @see WP_Filesystem_Base + * @phpstan-type Options array{ + * hostname: non-empty-string, + * username: non-empty-string, + * password: string, + * port: non-negative-int, + * connection_type?: 'ftps', + * ssl: bool, + * } */ class WP_Filesystem_FTPext extends WP_Filesystem_Base { @@ -21,6 +29,13 @@ class WP_Filesystem_FTPext extends WP_Filesystem_Base { */ public $link; + /** + * @since 7.1.0 + * @var array + * @phpstan-var Options + */ + public $options; + /** * Constructor. * @@ -35,8 +50,15 @@ class WP_Filesystem_FTPext extends WP_Filesystem_Base { * @type int $port Optional. FTP server port. Default 21. * @type string $connection_type Optional. Connection type. Use 'ftps' to enable SSL. * } + * @phpstan-param array{ + * hostname: non-empty-string, + * username: non-empty-string, + * password: string, + * port?: non-negative-int, + * connection_type?: 'ftps', + * }|null $opt */ - public function __construct( $opt = array() ) { + public function __construct( $opt = null ) { $this->method = 'ftpext'; $this->errors = new WP_Error(); @@ -51,35 +73,55 @@ public function __construct( $opt = array() ) { define( 'FS_TIMEOUT', 4 * MINUTE_IN_SECONDS ); } + $options = array(); + if ( ! is_array( $opt ) ) { + $opt = array(); + } + if ( empty( $opt['port'] ) ) { - $this->options['port'] = 21; + $options['port'] = 21; } else { - $this->options['port'] = $opt['port']; + $options['port'] = $opt['port']; } if ( empty( $opt['hostname'] ) ) { $this->errors->add( 'empty_hostname', __( 'FTP hostname is required' ) ); } else { - $this->options['hostname'] = $opt['hostname']; + $options['hostname'] = $opt['hostname']; } // Check if the options provided are OK. if ( empty( $opt['username'] ) ) { $this->errors->add( 'empty_username', __( 'FTP username is required' ) ); } else { - $this->options['username'] = $opt['username']; + $options['username'] = $opt['username']; } if ( empty( $opt['password'] ) ) { $this->errors->add( 'empty_password', __( 'FTP password is required' ) ); } else { - $this->options['password'] = $opt['password']; + $options['password'] = $opt['password']; } - $this->options['ssl'] = false; + $options['ssl'] = false; if ( isset( $opt['connection_type'] ) && 'ftps' === $opt['connection_type'] ) { - $this->options['ssl'] = true; + $options['ssl'] = true; + } + + if ( ! isset( $options['hostname'] ) ) { + $this->errors->add( 'empty_hostname', __( 'FTP hostname is required' ) ); + } + if ( ! isset( $options['username'] ) ) { + $this->errors->add( 'empty_username', __( 'FTP username is required' ) ); + } + if ( ! isset( $options['password'] ) ) { + $this->errors->add( 'empty_password', __( 'FTP password is required' ) ); + } + + if ( ! $this->errors->has_errors() ) { + /** @var Options $options */ + $this->options = $options; } } @@ -661,7 +703,7 @@ public function parselisting( $line ) { $b['year'] = $lucifer[3]; $b['hour'] = $lucifer[4]; $b['minute'] = $lucifer[5]; - $b['time'] = mktime( $lucifer[4] + ( strcasecmp( $lucifer[6], 'PM' ) === 0 ? 12 : 0 ), $lucifer[5], 0, $lucifer[1], $lucifer[2], $lucifer[3] ); + $b['time'] = mktime( (int) $lucifer[4] + ( strcasecmp( $lucifer[6], 'PM' ) === 0 ? 12 : 0 ), (int) $lucifer[5], 0, (int) $lucifer[1], (int) $lucifer[2], (int) $lucifer[3] ); $b['am/pm'] = $lucifer[6]; $b['name'] = $lucifer[8]; } elseif ( ! $is_windows ) { @@ -724,7 +766,7 @@ public function parselisting( $line ) { $b['name'] = preg_replace( '/(\s*->\s*.*)$/', '', $b['name'] ); } - return $b; + return $b ?? ''; } /** diff --git a/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php b/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php index 05a1580eb9475..5504d502d1e98 100644 --- a/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php +++ b/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php @@ -12,6 +12,12 @@ * @since 2.5.0 * * @see WP_Filesystem_Base + * @phpstan-type Options array{ + * hostname: non-empty-string, + * username: non-empty-string, + * password: string, + * port: non-negative-int, + * } */ class WP_Filesystem_ftpsockets extends WP_Filesystem_Base { @@ -21,6 +27,13 @@ class WP_Filesystem_ftpsockets extends WP_Filesystem_Base { */ public $ftp; + /** + * @since 7.1.0 + * @var array + * @phpstan-var Options + */ + public $options; + /** * Constructor. * @@ -34,8 +47,14 @@ class WP_Filesystem_ftpsockets extends WP_Filesystem_Base { * * @type string $password Required. FTP password. * * @type int $port Optional. FTP server port. Default 21. * * } + * @phpstan-param array{ + * hostname: non-empty-string, + * username: non-empty-string, + * password: string, + * port?: non-negative-int + * } $opt */ - public function __construct( $opt = array() ) { + public function __construct( $opt = null ) { $this->method = 'ftpsockets'; $this->errors = new WP_Error(); @@ -46,29 +65,39 @@ public function __construct( $opt = array() ) { $this->ftp = new ftp(); + $options = array(); + if ( ! is_array( $opt ) ) { + $opt = array(); + } + if ( empty( $opt['port'] ) ) { - $this->options['port'] = 21; + $options['port'] = 21; } else { - $this->options['port'] = (int) $opt['port']; + $options['port'] = (int) $opt['port']; } if ( empty( $opt['hostname'] ) ) { $this->errors->add( 'empty_hostname', __( 'FTP hostname is required' ) ); } else { - $this->options['hostname'] = $opt['hostname']; + $options['hostname'] = $opt['hostname']; } // Check if the options provided are OK. if ( empty( $opt['username'] ) ) { $this->errors->add( 'empty_username', __( 'FTP username is required' ) ); } else { - $this->options['username'] = $opt['username']; + $options['username'] = $opt['username']; } if ( empty( $opt['password'] ) ) { $this->errors->add( 'empty_password', __( 'FTP password is required' ) ); } else { - $this->options['password'] = $opt['password']; + $options['password'] = $opt['password']; + } + + if ( ! $this->errors->has_errors() ) { + /** @var Options $options */ + $this->options = $options; } } @@ -186,7 +215,7 @@ public function get_contents( $file ) { * @since 2.5.0 * * @param string $file Path to the file. - * @return array|false File contents in an array on success, false on failure. + * @return string[]|false File contents in an array on success, false on failure. */ public function get_contents_array( $file ) { return explode( "\n", $this->get_contents( $file ) ); diff --git a/src/wp-admin/includes/class-wp-filesystem-ssh2.php b/src/wp-admin/includes/class-wp-filesystem-ssh2.php index 32a3793335f39..9ae1aeb4c6c68 100644 --- a/src/wp-admin/includes/class-wp-filesystem-ssh2.php +++ b/src/wp-admin/includes/class-wp-filesystem-ssh2.php @@ -32,12 +32,22 @@ * * @package WordPress * @subpackage Filesystem + * + * @phpstan-type Options array{ + * hostname: non-empty-string, + * username?: non-empty-string, + * password?: string, + * port: non-negative-int, + * public_key?: non-empty-string, + * private_key?: non-empty-string, + * hostkey?: array{ hostkey: non-empty-string }, + * } */ class WP_Filesystem_SSH2 extends WP_Filesystem_Base { /** * @since 2.7.0 - * @var resource + * @var resource|false */ public $link = false; @@ -53,6 +63,13 @@ class WP_Filesystem_SSH2 extends WP_Filesystem_Base { */ public $keys = false; + /** + * @since 7.1.0 + * @var array + * @phpstan-var Options + */ + public $options; + /** * Constructor. * @@ -69,8 +86,17 @@ class WP_Filesystem_SSH2 extends WP_Filesystem_Base { * @type string $private_key Optional. Path to private key file for publickey authentication. * @type array $hostkey Optional. Hostkey options passed to ssh2_connect. * } - */ - public function __construct( $opt = array() ) { + * @phpstan-param array{ + * hostname: non-empty-string, + * username?: non-empty-string, + * password?: string, + * port?: non-negative-int, + * public_key?: non-empty-string, + * private_key?: non-empty-string, + * hostkey?: array{ hostkey: non-empty-string }, + * }|null $opt + */ + public function __construct( $opt = null ) { $this->method = 'ssh2'; $this->errors = new WP_Error(); @@ -80,25 +106,30 @@ public function __construct( $opt = array() ) { return; } + $options = array(); + if ( ! is_array( $opt ) ) { + $opt = array(); + } + // Set defaults: if ( empty( $opt['port'] ) ) { - $this->options['port'] = 22; + $options['port'] = 22; } else { - $this->options['port'] = $opt['port']; + $options['port'] = $opt['port']; } if ( empty( $opt['hostname'] ) ) { $this->errors->add( 'empty_hostname', __( 'SSH2 hostname is required' ) ); } else { - $this->options['hostname'] = $opt['hostname']; + $options['hostname'] = $opt['hostname']; } // Check if the options provided are OK. if ( ! empty( $opt['public_key'] ) && ! empty( $opt['private_key'] ) ) { - $this->options['public_key'] = $opt['public_key']; - $this->options['private_key'] = $opt['private_key']; + $options['public_key'] = $opt['public_key']; + $options['private_key'] = $opt['private_key']; - $this->options['hostkey'] = array( 'hostkey' => 'ssh-rsa,ssh-ed25519' ); + $options['hostkey'] = array( 'hostkey' => 'ssh-rsa,ssh-ed25519' ); $this->keys = true; } elseif ( empty( $opt['username'] ) ) { @@ -106,7 +137,7 @@ public function __construct( $opt = array() ) { } if ( ! empty( $opt['username'] ) ) { - $this->options['username'] = $opt['username']; + $options['username'] = $opt['username']; } if ( empty( $opt['password'] ) ) { @@ -114,10 +145,15 @@ public function __construct( $opt = array() ) { if ( ! $this->keys ) { $this->errors->add( 'empty_password', __( 'SSH2 password is required' ) ); } else { - $this->options['password'] = null; + $options['password'] = null; } } else { - $this->options['password'] = $opt['password']; + $options['password'] = $opt['password']; + } + + if ( ! $this->errors->has_errors() ) { + /** @var Options $options */ + $this->options = $options; } } @@ -274,7 +310,7 @@ public function get_contents( $file ) { * @since 2.7.0 * * @param string $file Path to the file. - * @return array|false File contents in an array on success, false on failure. + * @return string[]|false File contents in an array on success, false on failure. */ public function get_contents_array( $file ) { return file( $this->sftp_path( $file ) ); @@ -418,7 +454,7 @@ public function chown( $file, $owner, $recursive = false ) { * @since 2.7.0 * * @param string $file Path to the file. - * @return string|false Username of the owner on success, false on failure. + * @return string|int<1, max>|false Username of the owner on success, or UID of file owner if not available; false on failure. */ public function owner( $file ) { $owneruid = @fileowner( $this->sftp_path( $file ) ); @@ -458,7 +494,7 @@ public function getchmod( $file ) { * @since 2.7.0 * * @param string $file Path to the file. - * @return string|false The group on success, false on failure. + * @return string|int<1, max>|false Username of the owner on success, or group ID of file owner if not available; false on failure. */ public function group( $file ) { $gid = @filegroup( $this->sftp_path( $file ) ); From 02ebadb07b6d59e88ebc396dfd0c1862701135c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20W=C3=BCnsch?= Date: Tue, 9 Jun 2026 13:44:49 +0200 Subject: [PATCH 05/41] Apply suggestion from @westonruter Co-authored-by: Weston Ruter --- src/wp-admin/includes/class-wp-filesystem-ssh2.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-admin/includes/class-wp-filesystem-ssh2.php b/src/wp-admin/includes/class-wp-filesystem-ssh2.php index 9ae1aeb4c6c68..6ca1ab589762d 100644 --- a/src/wp-admin/includes/class-wp-filesystem-ssh2.php +++ b/src/wp-admin/includes/class-wp-filesystem-ssh2.php @@ -76,7 +76,7 @@ class WP_Filesystem_SSH2 extends WP_Filesystem_Base { * @since 2.7.0 * * @param array $opt { - * Optional. Array of connection options. + * Array of connection options. * * @type string $hostname Required. SSH server hostname. * @type int $port Optional. SSH server port. Default 22. From a9b1a4b1adb2d4d241f552361e688b2db6bc7950 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20W=C3=BCnsch?= Date: Tue, 9 Jun 2026 13:49:22 +0200 Subject: [PATCH 06/41] Apply suggestion from @westonruter Co-authored-by: Weston Ruter --- src/wp-admin/includes/class-wp-filesystem-ftpext.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-admin/includes/class-wp-filesystem-ftpext.php b/src/wp-admin/includes/class-wp-filesystem-ftpext.php index e2b8b5ba9e04a..d5c6fa8811f7f 100644 --- a/src/wp-admin/includes/class-wp-filesystem-ftpext.php +++ b/src/wp-admin/includes/class-wp-filesystem-ftpext.php @@ -42,7 +42,7 @@ class WP_Filesystem_FTPext extends WP_Filesystem_Base { * @since 2.5.0 * * @param array $opt { - * Optional. Array of connection options. + * Array of connection options. * * @type string $hostname Required. FTP server hostname. * @type string $username Required. FTP username. From 1ebb4a742aa8d52c7be372a5d3677bf9d16395c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20W=C3=BCnsch?= Date: Tue, 9 Jun 2026 13:49:42 +0200 Subject: [PATCH 07/41] Apply suggestion from @westonruter Co-authored-by: Weston Ruter --- .../includes/class-wp-filesystem-ftpsockets.php | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php b/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php index 5504d502d1e98..85f2c192e48f4 100644 --- a/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php +++ b/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php @@ -40,13 +40,13 @@ class WP_Filesystem_ftpsockets extends WP_Filesystem_Base { * @since 2.5.0 * * @param array $opt { - * * Optional. Array of connection options. - * * - * * @type string $hostname Required. FTP server hostname. - * * @type string $username Required. FTP username. - * * @type string $password Required. FTP password. - * * @type int $port Optional. FTP server port. Default 21. - * * } + * Array of connection options. + * + * @type string $hostname Required. FTP server hostname. + * @type string $username Required. FTP username. + * @type string $password Required. FTP password. + * @type int $port Optional. FTP server port. Default 21. + * } * @phpstan-param array{ * hostname: non-empty-string, * username: non-empty-string, From 0c73f676d9effa853013b03a57163e36e93a2912 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20W=C3=BCnsch?= Date: Tue, 9 Jun 2026 13:59:53 +0200 Subject: [PATCH 08/41] Fix whitespace --- src/wp-admin/includes/class-wp-filesystem-ftpsockets.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php b/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php index 85f2c192e48f4..ac24914905a5a 100644 --- a/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php +++ b/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php @@ -41,7 +41,7 @@ class WP_Filesystem_ftpsockets extends WP_Filesystem_Base { * * @param array $opt { * Array of connection options. - * + * * @type string $hostname Required. FTP server hostname. * @type string $username Required. FTP username. * @type string $password Required. FTP password. From b3d2fcd65a41d2f8de08754c800eb36da8da4d18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20W=C3=BCnsch?= Date: Tue, 9 Jun 2026 14:06:28 +0200 Subject: [PATCH 09/41] Update src/wp-admin/includes/class-wp-filesystem-ssh2.php Co-authored-by: Weston Ruter --- src/wp-admin/includes/class-wp-filesystem-ssh2.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-admin/includes/class-wp-filesystem-ssh2.php b/src/wp-admin/includes/class-wp-filesystem-ssh2.php index 6ca1ab589762d..2cb320454e45a 100644 --- a/src/wp-admin/includes/class-wp-filesystem-ssh2.php +++ b/src/wp-admin/includes/class-wp-filesystem-ssh2.php @@ -36,7 +36,7 @@ * @phpstan-type Options array{ * hostname: non-empty-string, * username?: non-empty-string, - * password?: string, + * password: string|null, * port: non-negative-int, * public_key?: non-empty-string, * private_key?: non-empty-string, From 064212c17b9cf63d2fa3dac0469aed92876ca17b Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 9 Jun 2026 09:57:15 -0700 Subject: [PATCH 10/41] Remove connection_type from stored options --- src/wp-admin/includes/class-wp-filesystem-ftpext.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/wp-admin/includes/class-wp-filesystem-ftpext.php b/src/wp-admin/includes/class-wp-filesystem-ftpext.php index d5c6fa8811f7f..a454266ee30f4 100644 --- a/src/wp-admin/includes/class-wp-filesystem-ftpext.php +++ b/src/wp-admin/includes/class-wp-filesystem-ftpext.php @@ -17,7 +17,6 @@ * username: non-empty-string, * password: string, * port: non-negative-int, - * connection_type?: 'ftps', * ssl: bool, * } */ From a242c46813ac08128edc410272e37f8608c80d03 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 9 Jun 2026 10:00:29 -0700 Subject: [PATCH 11/41] Fix return type fro cwd() method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Sören Wünsch --- src/wp-admin/includes/class-wp-filesystem-ssh2.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-admin/includes/class-wp-filesystem-ssh2.php b/src/wp-admin/includes/class-wp-filesystem-ssh2.php index 2cb320454e45a..7de1182f88ac4 100644 --- a/src/wp-admin/includes/class-wp-filesystem-ssh2.php +++ b/src/wp-admin/includes/class-wp-filesystem-ssh2.php @@ -344,7 +344,7 @@ public function put_contents( $file, $contents, $mode = false ) { * * @since 2.7.0 * - * @return string|false The current working directory on success, false on failure. + * @return string The current working directory. */ public function cwd() { $cwd = ssh2_sftp_realpath( $this->sftp_link, '.' ); From 6373bf8ddcc299b574700cb90a4372c667a78f29 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 9 Jun 2026 10:02:25 -0700 Subject: [PATCH 12/41] Suggest ext-ssh2 in composer.json --- composer.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 7c99860b9b347..f4514cd6c4d3d 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,8 @@ }, "suggest": { "ext-dom": "*", - "ext-ftp": "*" + "ext-ftp": "*", + "ext-ssh2": "*" }, "require-dev": { "composer/ca-bundle": "1.5.11", From 787326f13e5d81d0d715279dc528c467647161da Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 9 Jun 2026 10:09:19 -0700 Subject: [PATCH 13/41] Fix error case for WP_Filesystem_FTPext::get_contents_array() and use string[] --- src/wp-admin/includes/class-wp-filesystem-direct.php | 2 +- src/wp-admin/includes/class-wp-filesystem-ftpext.php | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/wp-admin/includes/class-wp-filesystem-direct.php b/src/wp-admin/includes/class-wp-filesystem-direct.php index c749b345b0730..5053a7f7f8d0b 100644 --- a/src/wp-admin/includes/class-wp-filesystem-direct.php +++ b/src/wp-admin/includes/class-wp-filesystem-direct.php @@ -46,7 +46,7 @@ public function get_contents( $file ) { * @since 2.5.0 * * @param string $file Path to the file. - * @return array|false File contents in an array on success, false on failure. + * @return string[]|false File contents in an array on success, false on failure. */ public function get_contents_array( $file ) { return @file( $file ); diff --git a/src/wp-admin/includes/class-wp-filesystem-ftpext.php b/src/wp-admin/includes/class-wp-filesystem-ftpext.php index a454266ee30f4..156fc8a70d66b 100644 --- a/src/wp-admin/includes/class-wp-filesystem-ftpext.php +++ b/src/wp-admin/includes/class-wp-filesystem-ftpext.php @@ -217,10 +217,14 @@ public function get_contents( $file ) { * @since 2.5.0 * * @param string $file Path to the file. - * @return array|false File contents in an array on success, false on failure. + * @return string[]|false File contents in an array on success, false on failure. */ public function get_contents_array( $file ) { - return explode( "\n", $this->get_contents( $file ) ); + $contents = $this->get_contents( $file ); + if ( is_string( $contents ) ) { + return explode( "\n", $contents ); + } + return false; } /** From 2e814518cd495436ce1d5958767c8abdc0a6fb91 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 9 Jun 2026 10:42:32 -0700 Subject: [PATCH 14/41] Add FileListing type to WP_Filesystem_FTPext --- .../includes/class-wp-filesystem-ftpext.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/wp-admin/includes/class-wp-filesystem-ftpext.php b/src/wp-admin/includes/class-wp-filesystem-ftpext.php index 156fc8a70d66b..8385b7728f27f 100644 --- a/src/wp-admin/includes/class-wp-filesystem-ftpext.php +++ b/src/wp-admin/includes/class-wp-filesystem-ftpext.php @@ -19,6 +19,20 @@ * port: non-negative-int, * ssl: bool, * } + * @phpstan-type FileListing array{ + * name: string, + * perms?: string, + * permsn?: string, + * number?: int|string|false, + * owner?: string|false, + * group?: string|false, + * size: int|string|false, + * lastmodunix?: int|string|false, + * lastmod?: string|false, + * time: string|false, + * type: string, + * files?: mixed[]|false, // The mixed[] is actually FileListing[] but PHPStan does not support recursive or self-referencing array shapes. + * } */ class WP_Filesystem_FTPext extends WP_Filesystem_Base { @@ -675,6 +689,7 @@ public function rmdir( $path, $recursive = false ) { * @type array|false $files If a directory and `$recursive` is true, contains another array of files. * False if unable to list directory contents. * } + * @phpstan-return FileListing|'' */ public function parselisting( $line ) { static $is_windows = null; @@ -806,6 +821,7 @@ public function parselisting( $line ) { * files. False if unable to list directory contents. * } * } + * @phpstan-return FileListing[]|false */ public function dirlist( $path = '.', $include_hidden = true, $recursive = false ) { if ( $this->is_file( $path ) ) { From dd695ae6c693b41b9ec5a80161ff3879cd25bbb6 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 9 Jun 2026 10:43:22 -0700 Subject: [PATCH 15/41] Avoid using false link in FTP methods --- .../includes/class-wp-filesystem-ftpext.php | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/wp-admin/includes/class-wp-filesystem-ftpext.php b/src/wp-admin/includes/class-wp-filesystem-ftpext.php index 8385b7728f27f..e664f7ae9cfab 100644 --- a/src/wp-admin/includes/class-wp-filesystem-ftpext.php +++ b/src/wp-admin/includes/class-wp-filesystem-ftpext.php @@ -201,6 +201,10 @@ public function get_contents( $file ) { $tempfile = wp_tempnam( $file ); $temphandle = fopen( $tempfile, 'w+' ); + if ( ! $this->link ) { + return false; + } + if ( ! $temphandle ) { unlink( $tempfile ); return false; @@ -532,9 +536,17 @@ public function is_file( $file ) { * @return bool Whether $path is a directory. */ public function is_dir( $path ) { - $cwd = $this->cwd(); + $cwd = $this->cwd(); + if ( false === $cwd ) { + return false; + } + $result = @ftp_chdir( $this->link, trailingslashit( $path ) ); + if ( ! $this->link ) { + return false; + } + if ( $result && $path === $this->cwd() || $this->cwd() !== $cwd ) { @ftp_chdir( $this->link, $cwd ); return true; @@ -824,6 +836,10 @@ public function parselisting( $line ) { * @phpstan-return FileListing[]|false */ public function dirlist( $path = '.', $include_hidden = true, $recursive = false ) { + if ( ! $this->link ) { + return false; + } + if ( $this->is_file( $path ) ) { $limit_file = basename( $path ); $path = dirname( $path ) . '/'; From f6cca38c20354dbf3cc5511cb214315c94788077 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 9 Jun 2026 10:44:01 -0700 Subject: [PATCH 16/41] Add remaining type casts and type checks for WP_Filesystem_FTPext --- src/wp-admin/includes/class-wp-filesystem-ftpext.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/wp-admin/includes/class-wp-filesystem-ftpext.php b/src/wp-admin/includes/class-wp-filesystem-ftpext.php index e664f7ae9cfab..e0a5129b7540b 100644 --- a/src/wp-admin/includes/class-wp-filesystem-ftpext.php +++ b/src/wp-admin/includes/class-wp-filesystem-ftpext.php @@ -769,7 +769,7 @@ public function parselisting( $line ) { sscanf( $lucifer[5], '%d-%d-%d', $b['year'], $b['month'], $b['day'] ); sscanf( $lucifer[6], '%d:%d', $b['hour'], $b['minute'] ); - $b['time'] = mktime( $b['hour'], $b['minute'], 0, $b['month'], $b['day'], $b['year'] ); + $b['time'] = mktime( (int) $b['hour'], (int) $b['minute'], 0, (int) $b['month'], (int) $b['day'], (int) $b['year'] ); $b['name'] = $lucifer[7]; } else { $b['month'] = $lucifer[5]; @@ -793,7 +793,7 @@ public function parselisting( $line ) { // Replace symlinks formatted as "source -> target" with just the source name. if ( isset( $b['islink'] ) && $b['islink'] ) { - $b['name'] = preg_replace( '/(\s*->\s*.*)$/', '', $b['name'] ); + $b['name'] = (string) preg_replace( '/(\s*->\s*.*)$/', '', $b['name'] ); } return $b ?? ''; @@ -848,11 +848,15 @@ public function dirlist( $path = '.', $include_hidden = true, $recursive = false } $pwd = ftp_pwd( $this->link ); + if ( ! is_string( $pwd ) ) { + return false; + } if ( ! @ftp_chdir( $this->link, $path ) ) { // Can't change to folder = folder doesn't exist. return false; } + /** @var string[]|false $list */ $list = ftp_rawlist( $this->link, '-a', false ); @ftp_chdir( $this->link, $pwd ); From 338a08f87507b16b509d3f7a695da68e0ea46c0f Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 9 Jun 2026 10:55:55 -0700 Subject: [PATCH 17/41] Move FileListing to WP_Filesystem_Base --- .../includes/class-wp-filesystem-base.php | 15 +++++++++++++++ .../includes/class-wp-filesystem-ftpext.php | 15 +-------------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/src/wp-admin/includes/class-wp-filesystem-base.php b/src/wp-admin/includes/class-wp-filesystem-base.php index e89dc20eb8f76..87a5b8a217a30 100644 --- a/src/wp-admin/includes/class-wp-filesystem-base.php +++ b/src/wp-admin/includes/class-wp-filesystem-base.php @@ -10,6 +10,21 @@ * Base WordPress Filesystem class which Filesystem implementations extend. * * @since 2.5.0 + * + * @phpstan-type FileListing array{ + * name: string, + * perms?: string, + * permsn?: string, + * number?: int|string|false, + * owner?: string|false, + * group?: string|false, + * size: int|string|false, + * lastmodunix?: int|string|false, + * lastmod?: string|false, + * time: string|false, + * type: string, + * files?: mixed[]|false, // The mixed[] is actually FileListing[] but PHPStan does not support recursive or self-referencing array shapes. + * } */ #[AllowDynamicProperties] class WP_Filesystem_Base { diff --git a/src/wp-admin/includes/class-wp-filesystem-ftpext.php b/src/wp-admin/includes/class-wp-filesystem-ftpext.php index e0a5129b7540b..494737469af5b 100644 --- a/src/wp-admin/includes/class-wp-filesystem-ftpext.php +++ b/src/wp-admin/includes/class-wp-filesystem-ftpext.php @@ -19,20 +19,7 @@ * port: non-negative-int, * ssl: bool, * } - * @phpstan-type FileListing array{ - * name: string, - * perms?: string, - * permsn?: string, - * number?: int|string|false, - * owner?: string|false, - * group?: string|false, - * size: int|string|false, - * lastmodunix?: int|string|false, - * lastmod?: string|false, - * time: string|false, - * type: string, - * files?: mixed[]|false, // The mixed[] is actually FileListing[] but PHPStan does not support recursive or self-referencing array shapes. - * } + * @phpstan-import-type FileListing from WP_Filesystem_Base */ class WP_Filesystem_FTPext extends WP_Filesystem_Base { From 7437fff2c900f67e56b2f31afe5bf5768c699044 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 9 Jun 2026 10:56:41 -0700 Subject: [PATCH 18/41] Address PHPStan errors in WP_Filesystem_Base --- .../includes/class-wp-filesystem-base.php | 38 +++++++++++-------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/src/wp-admin/includes/class-wp-filesystem-base.php b/src/wp-admin/includes/class-wp-filesystem-base.php index 87a5b8a217a30..27d66102f8a57 100644 --- a/src/wp-admin/includes/class-wp-filesystem-base.php +++ b/src/wp-admin/includes/class-wp-filesystem-base.php @@ -41,7 +41,7 @@ class WP_Filesystem_Base { * Cached list of local filepaths to mapped remote filepaths. * * @since 2.7.0 - * @var array + * @var array */ public $cache = array(); @@ -68,7 +68,7 @@ class WP_Filesystem_Base { * * @since 2.7.0 * - * @return string The location of the remote path. + * @return string|false The location of the remote path, or false on failure. */ public function abspath() { $folder = $this->find_folder( ABSPATH ); @@ -89,7 +89,7 @@ public function abspath() { * * @since 2.7.0 * - * @return string The location of the remote path. + * @return string|false The location of the remote path, or false on failure. */ public function wp_content_dir() { return $this->find_folder( WP_CONTENT_DIR ); @@ -100,7 +100,7 @@ public function wp_content_dir() { * * @since 2.7.0 * - * @return string The location of the remote path. + * @return string|false The location of the remote path, or false on failure. */ public function wp_plugins_dir() { return $this->find_folder( WP_PLUGIN_DIR ); @@ -113,10 +113,10 @@ public function wp_plugins_dir() { * * @param string|false $theme Optional. The theme stylesheet or template for the directory. * Default false. - * @return string The location of the remote path. + * @return string|false The location of the remote path, or false on failure. */ public function wp_themes_dir( $theme = false ) { - $theme_root = get_theme_root( $theme ); + $theme_root = get_theme_root( is_string( $theme ) ? $theme : '' ); // Account for relative theme roots. if ( '/themes' === $theme_root || ! is_dir( $theme_root ) ) { @@ -131,7 +131,7 @@ public function wp_themes_dir( $theme = false ) { * * @since 3.2.0 * - * @return string The location of the remote path. + * @return string|false The location of the remote path, or false on failure. */ public function wp_lang_dir() { return $this->find_folder( WP_LANG_DIR ); @@ -150,7 +150,7 @@ public function wp_lang_dir() { * * @param string $base Optional. The folder to start searching from. Default '.'. * @param bool $verbose Optional. True to display debug information. Default false. - * @return string The location of the remote path. + * @return string|false The location of the remote path, or false on failure. */ public function find_base_dir( $base = '.', $verbose = false ) { _deprecated_function( __FUNCTION__, '2.7.0', 'WP_Filesystem_Base::abspath() or WP_Filesystem_Base::wp_*_dir()' ); @@ -171,7 +171,7 @@ public function find_base_dir( $base = '.', $verbose = false ) { * * @param string $base Optional. The folder to start searching from. Default '.'. * @param bool $verbose Optional. True to display debug information. Default false. - * @return string The location of the remote path. + * @return string|false The location of the remote path, or false on failure. */ public function get_base_dir( $base = '.', $verbose = false ) { _deprecated_function( __FUNCTION__, '2.7.0', 'WP_Filesystem_Base::abspath() or WP_Filesystem_Base::wp_*_dir()' ); @@ -210,7 +210,9 @@ public function find_folder( $folder ) { } if ( $folder === $dir ) { - return trailingslashit( constant( $constant ) ); + /** @var string $constant_value */ + $constant_value = constant( $constant ); + return trailingslashit( $constant_value ); } } @@ -221,7 +223,9 @@ public function find_folder( $folder ) { } if ( 0 === stripos( $folder, $dir ) ) { // $folder starts with $dir. - $potential_folder = preg_replace( '#^' . preg_quote( $dir, '#' ) . '/#i', trailingslashit( constant( $constant ) ), $folder ); + /** @var string $constant_value */ + $constant_value = constant( $constant ); + $potential_folder = (string) preg_replace( '#^' . preg_quote( $dir, '#' ) . '/#i', trailingslashit( $constant_value ), $folder ); $potential_folder = trailingslashit( $potential_folder ); if ( $this->is_dir( $potential_folder ) ) { @@ -237,7 +241,7 @@ public function find_folder( $folder ) { return trailingslashit( $folder ); } - $folder = preg_replace( '|^([a-z]{1}):|i', '', $folder ); // Strip out Windows drive letter if it's there. + $folder = (string) preg_replace( '|^([a-z]{1}):|i', '', $folder ); // Strip out Windows drive letter if it's there. $folder = str_replace( '\\', '/', $folder ); // Windows path sanitization. if ( isset( $this->cache[ $folder ] ) ) { @@ -274,7 +278,10 @@ public function find_folder( $folder ) { */ public function search_for_folder( $folder, $base = '.', $loop = false ) { if ( empty( $base ) || '.' === $base ) { - $base = trailingslashit( $this->cwd() ); + $cwd = $this->cwd(); + if ( is_string( $cwd ) ) { + $base = trailingslashit( $cwd ); + } } $folder = untrailingslashit( $folder ); @@ -436,7 +443,7 @@ public function getchmod( $file ) { public function getnumchmodfromh( $mode ) { $realmode = ''; $legal = array( '', 'w', 'r', 'x', '-' ); - $attarray = preg_split( '//', $mode ); + $attarray = (array) preg_split( '//', $mode ); for ( $i = 0, $c = count( $attarray ); $i < $c; $i++ ) { $key = array_search( $attarray[ $i ], $legal, true ); @@ -524,7 +531,7 @@ public function get_contents( $file ) { * @abstract * * @param string $file Path to the file. - * @return array|false File contents in an array on success, false on failure. + * @return string[]|false File contents in an array on success, false on failure. */ public function get_contents_array( $file ) { return false; @@ -873,6 +880,7 @@ public function rmdir( $path, $recursive = false ) { * files. False if unable to list directory contents. * } * } + * @phpstan-return FileListing[]|false */ public function dirlist( $path, $include_hidden = true, $recursive = false ) { return false; From 8495cf854fa3b65238b8343b52b077d9113b9209 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 9 Jun 2026 11:08:33 -0700 Subject: [PATCH 19/41] Add missing comma --- src/wp-admin/includes/class-wp-filesystem-ftpsockets.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php b/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php index ac24914905a5a..11953f14df0a7 100644 --- a/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php +++ b/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php @@ -51,7 +51,7 @@ class WP_Filesystem_ftpsockets extends WP_Filesystem_Base { * hostname: non-empty-string, * username: non-empty-string, * password: string, - * port?: non-negative-int + * port?: non-negative-int, * } $opt */ public function __construct( $opt = null ) { From b8c873ba53ef0c3bb0a24377f3ba3177440ad983 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 9 Jun 2026 11:09:09 -0700 Subject: [PATCH 20/41] Add FileListing types --- src/wp-admin/includes/class-wp-filesystem-direct.php | 2 ++ src/wp-admin/includes/class-wp-filesystem-ftpsockets.php | 2 ++ src/wp-admin/includes/class-wp-filesystem-ssh2.php | 2 ++ 3 files changed, 6 insertions(+) diff --git a/src/wp-admin/includes/class-wp-filesystem-direct.php b/src/wp-admin/includes/class-wp-filesystem-direct.php index 5053a7f7f8d0b..9c3f6d1072066 100644 --- a/src/wp-admin/includes/class-wp-filesystem-direct.php +++ b/src/wp-admin/includes/class-wp-filesystem-direct.php @@ -12,6 +12,7 @@ * @since 2.5.0 * * @see WP_Filesystem_Base + * @phpstan-import-type FileListing from WP_Filesystem_Base */ class WP_Filesystem_Direct extends WP_Filesystem_Base { @@ -640,6 +641,7 @@ public function rmdir( $path, $recursive = false ) { * files. False if unable to list directory contents. * } * } + * @phpstan-return FileListing[]|false */ public function dirlist( $path, $include_hidden = true, $recursive = false ) { if ( $this->is_file( $path ) ) { diff --git a/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php b/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php index 11953f14df0a7..01862f7dfb2ba 100644 --- a/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php +++ b/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php @@ -18,6 +18,7 @@ * password: string, * port: non-negative-int, * } + * @phpstan-import-type FileListing from WP_Filesystem_Base */ class WP_Filesystem_ftpsockets extends WP_Filesystem_Base { @@ -682,6 +683,7 @@ public function rmdir( $path, $recursive = false ) { * files. False if unable to list directory contents. * } * } + * @phpstan-return FileListing[]|false */ public function dirlist( $path = '.', $include_hidden = true, $recursive = false ) { if ( $this->is_file( $path ) ) { diff --git a/src/wp-admin/includes/class-wp-filesystem-ssh2.php b/src/wp-admin/includes/class-wp-filesystem-ssh2.php index 7de1182f88ac4..8c9cb5106beaf 100644 --- a/src/wp-admin/includes/class-wp-filesystem-ssh2.php +++ b/src/wp-admin/includes/class-wp-filesystem-ssh2.php @@ -42,6 +42,7 @@ * private_key?: non-empty-string, * hostkey?: array{ hostkey: non-empty-string }, * } + * @phpstan-import-type FileListing from WP_Filesystem_Base */ class WP_Filesystem_SSH2 extends WP_Filesystem_Base { @@ -814,6 +815,7 @@ public function rmdir( $path, $recursive = false ) { * files. False if unable to list directory contents. * } * } + * @phpstan-return FileListing[]|false */ public function dirlist( $path, $include_hidden = true, $recursive = false ) { if ( $this->is_file( $path ) ) { From 459fd6e2a6a372c4386cb8891288877489db20ae Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 9 Jun 2026 11:12:45 -0700 Subject: [PATCH 21/41] Fix errors with failing to pass string to chown and chgrp --- .../includes/class-wp-filesystem-direct.php | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/wp-admin/includes/class-wp-filesystem-direct.php b/src/wp-admin/includes/class-wp-filesystem-direct.php index 9c3f6d1072066..27e1d8296113f 100644 --- a/src/wp-admin/includes/class-wp-filesystem-direct.php +++ b/src/wp-admin/includes/class-wp-filesystem-direct.php @@ -140,9 +140,12 @@ public function chgrp( $file, $group, $recursive = false ) { // Is a directory, and we want recursive. $file = trailingslashit( $file ); $filelist = $this->dirlist( $file ); + if ( false === $filelist ) { + return false; + } - foreach ( $filelist as $filename ) { - $this->chgrp( $file . $filename, $group, $recursive ); + foreach ( $filelist as $file_listing ) { + $this->chgrp( $file . $file_listing['name'], $group, $recursive ); } return true; @@ -228,9 +231,12 @@ public function chown( $file, $owner, $recursive = false ) { // Is a directory, and we want recursive. $filelist = $this->dirlist( $file ); + if ( false === $filelist ) { + return false; + } - foreach ( $filelist as $filename ) { - $this->chown( $file . '/' . $filename, $owner, $recursive ); + foreach ( $filelist as $file_listing ) { + $this->chown( $file . '/' . $file_listing['name'], $owner, $recursive ); } return true; From e0e07c085b142946cdd46be2df58963b18355a8f Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 9 Jun 2026 11:19:40 -0700 Subject: [PATCH 22/41] Fix return type of dirlist() --- src/wp-admin/includes/class-wp-filesystem-base.php | 2 +- src/wp-admin/includes/class-wp-filesystem-direct.php | 2 +- src/wp-admin/includes/class-wp-filesystem-ftpext.php | 2 +- src/wp-admin/includes/class-wp-filesystem-ftpsockets.php | 2 +- src/wp-admin/includes/class-wp-filesystem-ssh2.php | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/wp-admin/includes/class-wp-filesystem-base.php b/src/wp-admin/includes/class-wp-filesystem-base.php index 27d66102f8a57..e81d4cd44d3aa 100644 --- a/src/wp-admin/includes/class-wp-filesystem-base.php +++ b/src/wp-admin/includes/class-wp-filesystem-base.php @@ -880,7 +880,7 @@ public function rmdir( $path, $recursive = false ) { * files. False if unable to list directory contents. * } * } - * @phpstan-return FileListing[]|false + * @phpstan-return array|false */ public function dirlist( $path, $include_hidden = true, $recursive = false ) { return false; diff --git a/src/wp-admin/includes/class-wp-filesystem-direct.php b/src/wp-admin/includes/class-wp-filesystem-direct.php index 27e1d8296113f..c7cbe68f75103 100644 --- a/src/wp-admin/includes/class-wp-filesystem-direct.php +++ b/src/wp-admin/includes/class-wp-filesystem-direct.php @@ -647,7 +647,7 @@ public function rmdir( $path, $recursive = false ) { * files. False if unable to list directory contents. * } * } - * @phpstan-return FileListing[]|false + * @phpstan-return array|false */ public function dirlist( $path, $include_hidden = true, $recursive = false ) { if ( $this->is_file( $path ) ) { diff --git a/src/wp-admin/includes/class-wp-filesystem-ftpext.php b/src/wp-admin/includes/class-wp-filesystem-ftpext.php index 494737469af5b..1a846adee549a 100644 --- a/src/wp-admin/includes/class-wp-filesystem-ftpext.php +++ b/src/wp-admin/includes/class-wp-filesystem-ftpext.php @@ -820,7 +820,7 @@ public function parselisting( $line ) { * files. False if unable to list directory contents. * } * } - * @phpstan-return FileListing[]|false + * @phpstan-return array|false */ public function dirlist( $path = '.', $include_hidden = true, $recursive = false ) { if ( ! $this->link ) { diff --git a/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php b/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php index 01862f7dfb2ba..81c3cdbb27142 100644 --- a/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php +++ b/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php @@ -683,7 +683,7 @@ public function rmdir( $path, $recursive = false ) { * files. False if unable to list directory contents. * } * } - * @phpstan-return FileListing[]|false + * @phpstan-return array|false */ public function dirlist( $path = '.', $include_hidden = true, $recursive = false ) { if ( $this->is_file( $path ) ) { diff --git a/src/wp-admin/includes/class-wp-filesystem-ssh2.php b/src/wp-admin/includes/class-wp-filesystem-ssh2.php index 8c9cb5106beaf..26e58ab33fc0a 100644 --- a/src/wp-admin/includes/class-wp-filesystem-ssh2.php +++ b/src/wp-admin/includes/class-wp-filesystem-ssh2.php @@ -815,7 +815,7 @@ public function rmdir( $path, $recursive = false ) { * files. False if unable to list directory contents. * } * } - * @phpstan-return FileListing[]|false + * @phpstan-return array|false */ public function dirlist( $path, $include_hidden = true, $recursive = false ) { if ( $this->is_file( $path ) ) { From 0bc2602966eb4d45bafe2140cd95c01f6d241505 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 9 Jun 2026 11:50:06 -0700 Subject: [PATCH 23/41] Fix PHPStan rule level 7 errors --- .../includes/class-wp-filesystem-base.php | 4 +- .../includes/class-wp-filesystem-direct.php | 4 +- .../includes/class-wp-filesystem-ftpext.php | 4 +- .../class-wp-filesystem-ftpsockets.php | 20 ++++++--- .../includes/class-wp-filesystem-ssh2.php | 44 ++++++++++++++----- 5 files changed, 53 insertions(+), 23 deletions(-) diff --git a/src/wp-admin/includes/class-wp-filesystem-base.php b/src/wp-admin/includes/class-wp-filesystem-base.php index e81d4cd44d3aa..c9365943582f2 100644 --- a/src/wp-admin/includes/class-wp-filesystem-base.php +++ b/src/wp-admin/includes/class-wp-filesystem-base.php @@ -16,8 +16,8 @@ * perms?: string, * permsn?: string, * number?: int|string|false, - * owner?: string|false, - * group?: string|false, + * owner?: string|int<1, max>|false, + * group?: string|int<1, max>|false, * size: int|string|false, * lastmodunix?: int|string|false, * lastmod?: string|false, diff --git a/src/wp-admin/includes/class-wp-filesystem-direct.php b/src/wp-admin/includes/class-wp-filesystem-direct.php index c7cbe68f75103..af453482a32ee 100644 --- a/src/wp-admin/includes/class-wp-filesystem-direct.php +++ b/src/wp-admin/includes/class-wp-filesystem-direct.php @@ -693,8 +693,8 @@ public function dirlist( $path, $include_hidden = true, $recursive = false ) { $struc['group'] = $this->group( $path . $entry ); $struc['size'] = $this->size( $path . $entry ); $struc['lastmodunix'] = $this->mtime( $path . $entry ); - $struc['lastmod'] = gmdate( 'M j', $struc['lastmodunix'] ); - $struc['time'] = gmdate( 'h:i:s', $struc['lastmodunix'] ); + $struc['lastmod'] = is_string( $struc['lastmodunix'] ) ? gmdate( 'M j', $struc['lastmodunix'] ) : false; + $struc['time'] = is_string( $struc['lastmodunix'] ) ? gmdate( 'h:i:s', $struc['lastmodunix'] ) : false; $struc['type'] = $this->is_dir( $path . $entry ) ? 'd' : 'f'; if ( 'd' === $struc['type'] ) { diff --git a/src/wp-admin/includes/class-wp-filesystem-ftpext.php b/src/wp-admin/includes/class-wp-filesystem-ftpext.php index 1a846adee549a..48bfd6f258bf5 100644 --- a/src/wp-admin/includes/class-wp-filesystem-ftpext.php +++ b/src/wp-admin/includes/class-wp-filesystem-ftpext.php @@ -352,7 +352,7 @@ public function chmod( $file, $mode = false, $recursive = false ) { * @since 2.5.0 * * @param string $file Path to the file. - * @return string|false Username of the owner on success, false on failure. + * @return string|int<1, max>|false Username of the owner on success, false on failure. */ public function owner( $file ) { $dir = $this->dirlist( $file ); @@ -380,7 +380,7 @@ public function getchmod( $file ) { * @since 2.5.0 * * @param string $file Path to the file. - * @return string|false The group on success, false on failure. + * @return string|int<1, max>|false The group on success, false on failure. */ public function group( $file ) { $dir = $this->dirlist( $file ); diff --git a/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php b/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php index 81c3cdbb27142..50b40a32e9c20 100644 --- a/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php +++ b/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php @@ -219,7 +219,11 @@ public function get_contents( $file ) { * @return string[]|false File contents in an array on success, false on failure. */ public function get_contents_array( $file ) { - return explode( "\n", $this->get_contents( $file ) ); + $contents = $this->get_contents( $file ); + if ( is_string( $contents ) ) { + return explode( "\n", $contents ); + } + return false; } /** @@ -279,6 +283,9 @@ public function put_contents( $file, $contents, $mode = false ) { */ public function cwd() { $cwd = $this->ftp->pwd(); + if ( ! is_string( $cwd ) ) { + return false; + } if ( $cwd ) { $cwd = trailingslashit( $cwd ); @@ -341,7 +348,7 @@ public function chmod( $file, $mode = false, $recursive = false ) { * @since 2.5.0 * * @param string $file Path to the file. - * @return string|false Username of the owner on success, false on failure. + * @return string|int<1, max>|false Username of the owner on success, false on failure. */ public function owner( $file ) { $dir = $this->dirlist( $file ); @@ -369,7 +376,7 @@ public function getchmod( $file ) { * @since 2.5.0 * * @param string $file Path to the file. - * @return string|false The group on success, false on failure. + * @return string|int<1, max>|false The group on success, false on failure. */ public function group( $file ) { $dir = $this->dirlist( $file ); @@ -513,9 +520,12 @@ public function is_file( $file ) { * @return bool Whether $path is a directory. */ public function is_dir( $path ) { - $cwd = $this->cwd(); - if ( $this->chdir( $path ) ) { + $cwd = $this->cwd(); + if ( ! $cwd ) { + return false; + } + $this->chdir( $cwd ); return true; } diff --git a/src/wp-admin/includes/class-wp-filesystem-ssh2.php b/src/wp-admin/includes/class-wp-filesystem-ssh2.php index 26e58ab33fc0a..9c40b10922608 100644 --- a/src/wp-admin/includes/class-wp-filesystem-ssh2.php +++ b/src/wp-admin/includes/class-wp-filesystem-ssh2.php @@ -54,7 +54,7 @@ class WP_Filesystem_SSH2 extends WP_Filesystem_Base { /** * @since 2.7.0 - * @var resource + * @var resource|false */ public $sftp_link; @@ -166,7 +166,7 @@ public function __construct( $opt = null ) { * @return bool True on success, false on failure. */ public function connect() { - if ( ! $this->keys ) { + if ( ! isset( $this->options['hostkey'] ) ) { $this->link = @ssh2_connect( $this->options['hostname'], $this->options['port'] ); } else { $this->link = @ssh2_connect( $this->options['hostname'], $this->options['port'], $this->options['hostkey'] ); @@ -185,7 +185,7 @@ public function connect() { return false; } - if ( ! $this->keys ) { + if ( ! $this->keys && isset( $this->options['username'], $this->options['password'] ) ) { if ( ! @ssh2_auth_password( $this->link, $this->options['username'], $this->options['password'] ) ) { $this->errors->add( 'auth', @@ -198,7 +198,7 @@ public function connect() { return false; } - } else { + } elseif ( isset( $this->options['username'], $this->options['public_key'], $this->options['private_key'], $this->options['password'] ) ) { if ( ! @ssh2_auth_pubkey_file( $this->link, $this->options['username'], $this->options['public_key'], $this->options['private_key'], $this->options['password'] ) ) { $this->errors->add( 'auth', @@ -259,6 +259,8 @@ public function sftp_path( $path ) { * @param bool $returnbool * @return bool|string True on success, false on failure. String if the command was executed, `$returnbool` * is false (default), and data from the resulting stream was retrieved. + * + * @phpstan-return ( $returnbool is true ? bool : string ) */ public function run_command( $command, $returnbool = false ) { if ( ! $this->link ) { @@ -348,6 +350,11 @@ public function put_contents( $file, $contents, $mode = false ) { * @return string The current working directory. */ public function cwd() { + if ( ! $this->sftp_link ) { + return ''; + } + + /** @var string $cwd */ $cwd = ssh2_sftp_realpath( $this->sftp_link, '.' ); if ( $cwd ) { @@ -386,10 +393,10 @@ public function chgrp( $file, $group, $recursive = false ) { } if ( ! $recursive || ! $this->is_dir( $file ) ) { - return $this->run_command( sprintf( 'chgrp %s %s', escapeshellarg( $group ), escapeshellarg( $file ) ), true ); + return $this->run_command( sprintf( 'chgrp %s %s', escapeshellarg( (string) $group ), escapeshellarg( $file ) ), true ); } - return $this->run_command( sprintf( 'chgrp -R %s %s', escapeshellarg( $group ), escapeshellarg( $file ) ), true ); + return $this->run_command( sprintf( 'chgrp -R %s %s', escapeshellarg( (string) $group ), escapeshellarg( $file ) ), true ); } /** @@ -443,10 +450,10 @@ public function chown( $file, $owner, $recursive = false ) { } if ( ! $recursive || ! $this->is_dir( $file ) ) { - return $this->run_command( sprintf( 'chown %s %s', escapeshellarg( $owner ), escapeshellarg( $file ) ), true ); + return $this->run_command( sprintf( 'chown %s %s', escapeshellarg( (string) $owner ), escapeshellarg( $file ) ), true ); } - return $this->run_command( sprintf( 'chown -R %s %s', escapeshellarg( $owner ), escapeshellarg( $file ) ), true ); + return $this->run_command( sprintf( 'chown -R %s %s', escapeshellarg( (string) $owner ), escapeshellarg( $file ) ), true ); } /** @@ -483,10 +490,14 @@ public function owner( $file ) { * @since 2.7.0 * * @param string $file Path to the file. - * @return string Mode of the file (the last 3 digits). + * @return string Mode of the file (the last 3 digits). Empty string on failure. */ public function getchmod( $file ) { - return substr( decoct( @fileperms( $this->sftp_path( $file ) ) ), -3 ); + $file_perms = @fileperms( $this->sftp_path( $file ) ); + if ( ! is_int( $file_perms ) ) { + return ''; + } + return substr( decoct( $file_perms ), -3 ); } /** @@ -573,6 +584,9 @@ public function move( $source, $destination, $overwrite = false ) { } } + if ( ! $this->sftp_link ) { + return false; + } return ssh2_sftp_rename( $this->sftp_link, $source, $destination ); } @@ -589,6 +603,9 @@ public function move( $source, $destination, $overwrite = false ) { * @return bool True on success, false on failure. */ public function delete( $file, $recursive = false, $type = false ) { + if ( ! $this->sftp_link ) { + return false; + } if ( 'f' === $type || $this->is_file( $file ) ) { return ssh2_sftp_unlink( $this->sftp_link, $file ); } @@ -739,6 +756,9 @@ public function touch( $file, $time = 0, $atime = 0 ) { * @return bool True on success, false on failure. */ public function mkdir( $path, $chmod = false, $chown = false, $chgrp = false ) { + if ( ! $this->sftp_link ) { + return false; + } $path = untrailingslashit( $path ); if ( empty( $path ) ) { @@ -861,8 +881,8 @@ public function dirlist( $path, $include_hidden = true, $recursive = false ) { $struc['group'] = $this->group( $path . $entry ); $struc['size'] = $this->size( $path . $entry ); $struc['lastmodunix'] = $this->mtime( $path . $entry ); - $struc['lastmod'] = gmdate( 'M j', $struc['lastmodunix'] ); - $struc['time'] = gmdate( 'h:i:s', $struc['lastmodunix'] ); + $struc['lastmod'] = is_string( $struc['lastmodunix'] ) ? gmdate( 'M j', $struc['lastmodunix'] ) : false; + $struc['time'] = is_string( $struc['lastmodunix'] ) ? gmdate( 'h:i:s', $struc['lastmodunix'] ) : false; $struc['type'] = $this->is_dir( $path . $entry ) ? 'd' : 'f'; if ( 'd' === $struc['type'] ) { From 74594c520766fd292094a954d3f5630263b3e187 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 9 Jun 2026 12:32:04 -0700 Subject: [PATCH 24/41] Fix remaining PHPStan rule level 10 errors --- .../includes/class-wp-filesystem-base.php | 2 + .../class-wp-filesystem-ftpsockets.php | 37 ++++++++++++------- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/src/wp-admin/includes/class-wp-filesystem-base.php b/src/wp-admin/includes/class-wp-filesystem-base.php index c9365943582f2..b174ebc23d3be 100644 --- a/src/wp-admin/includes/class-wp-filesystem-base.php +++ b/src/wp-admin/includes/class-wp-filesystem-base.php @@ -23,6 +23,8 @@ * lastmod?: string|false, * time: string|false, * type: string, + * islink?: bool, + * isdir?: bool, * files?: mixed[]|false, // The mixed[] is actually FileListing[] but PHPStan does not support recursive or self-referencing array shapes. * } */ diff --git a/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php b/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php index 50b40a32e9c20..31ecb4469f22d 100644 --- a/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php +++ b/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php @@ -262,7 +262,7 @@ public function put_contents( $file, $contents, $mode = false ) { fseek( $temphandle, 0 ); // Skip back to the start of the file being written to. - $ret = $this->ftp->fput( $file, $temphandle ); + $ret = (bool) $this->ftp->fput( $file, $temphandle ); reset_mbstring_encoding(); @@ -303,7 +303,7 @@ public function cwd() { * @return bool True on success, false on failure. */ public function chdir( $dir ) { - return $this->ftp->chdir( $dir ); + return (bool) $this->ftp->chdir( $dir ); } /** @@ -339,7 +339,7 @@ public function chmod( $file, $mode = false, $recursive = false ) { } // chmod the file or directory. - return $this->ftp->chmod( $file, $mode ); + return (bool) $this->ftp->chmod( $file, $mode ); } /** @@ -430,7 +430,7 @@ public function copy( $source, $destination, $overwrite = false, $mode = false ) * @return bool True on success, false on failure. */ public function move( $source, $destination, $overwrite = false ) { - return $this->ftp->rename( $source, $destination ); + return (bool) $this->ftp->rename( $source, $destination ); } /** @@ -451,14 +451,14 @@ public function delete( $file, $recursive = false, $type = false ) { } if ( 'f' === $type || $this->is_file( $file ) ) { - return $this->ftp->delete( $file ); + return (bool) $this->ftp->delete( $file ); } if ( ! $recursive ) { - return $this->ftp->rmdir( $file ); + return (bool) $this->ftp->rmdir( $file ); } - return $this->ftp->mdel( $file ); + return (bool) $this->ftp->mdel( $file ); } /** @@ -578,7 +578,11 @@ public function atime( $file ) { * @return int|false Unix timestamp representing modification time, false on failure. */ public function mtime( $file ) { - return $this->ftp->mdtm( $file ); + $modified_time = $this->ftp->mdtm( $file ); + if ( false === $modified_time ) { + return false; + } + return (int) $modified_time; } /** @@ -590,7 +594,11 @@ public function mtime( $file ) { * @return int|false Size of the file in bytes on success, false on failure. */ public function size( $file ) { - return $this->ftp->filesize( $file ); + $size = $this->ftp->filesize( $file ); + if ( false === $size ) { + return false; + } + return (int) $size; } /** @@ -705,9 +713,10 @@ public function dirlist( $path = '.', $include_hidden = true, $recursive = false mbstring_binary_safe_encoding(); + /** @var array|false $list */ $list = $this->ftp->dirlist( $path ); - if ( empty( $list ) && ! $this->exists( $path ) ) { + if ( ! $list || ! $this->exists( $path ) ) { reset_mbstring_encoding(); @@ -740,12 +749,14 @@ public function dirlist( $path = '.', $include_hidden = true, $recursive = false } // Replace symlinks formatted as "source -> target" with just the source name. - if ( $struc['islink'] ) { - $struc['name'] = preg_replace( '/(\s*->\s*.*)$/', '', $struc['name'] ); + if ( $struc['islink'] ?? false ) { + $struc['name'] = (string) preg_replace( '/(\s*->\s*.*)$/', '', $struc['name'] ); } // Add the octal representation of the file permissions. - $struc['permsn'] = $this->getnumchmodfromh( $struc['perms'] ); + if ( isset( $struc['perms'] ) ) { + $struc['permsn'] = $this->getnumchmodfromh( $struc['perms'] ); + } $ret[ $struc['name'] ] = $struc; } From 3055b357cb3bc0f0da0036ad5d4644ffdc55c854 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 9 Jun 2026 12:40:50 -0700 Subject: [PATCH 25/41] Fix handling of null password before calling ssh2_auth_pubkey_file() Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/wp-admin/includes/class-wp-filesystem-ssh2.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/wp-admin/includes/class-wp-filesystem-ssh2.php b/src/wp-admin/includes/class-wp-filesystem-ssh2.php index 9c40b10922608..843cdaae12dc3 100644 --- a/src/wp-admin/includes/class-wp-filesystem-ssh2.php +++ b/src/wp-admin/includes/class-wp-filesystem-ssh2.php @@ -198,8 +198,7 @@ public function connect() { return false; } - } elseif ( isset( $this->options['username'], $this->options['public_key'], $this->options['private_key'], $this->options['password'] ) ) { - if ( ! @ssh2_auth_pubkey_file( $this->link, $this->options['username'], $this->options['public_key'], $this->options['private_key'], $this->options['password'] ) ) { + } elseif ( isset( $this->options['username'], $this->options['public_key'], $this->options['private_key'] ) && array_key_exists( 'password', $this->options ) ) { $this->errors->add( 'auth', sprintf( From f049eea67f64c94771d929b64378c702fff2ee1e Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 9 Jun 2026 12:43:29 -0700 Subject: [PATCH 26/41] Fix malformed Copilot suggestion in 3055b35 --- src/wp-admin/includes/class-wp-filesystem-ssh2.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/wp-admin/includes/class-wp-filesystem-ssh2.php b/src/wp-admin/includes/class-wp-filesystem-ssh2.php index 843cdaae12dc3..fe759462b2386 100644 --- a/src/wp-admin/includes/class-wp-filesystem-ssh2.php +++ b/src/wp-admin/includes/class-wp-filesystem-ssh2.php @@ -199,6 +199,7 @@ public function connect() { return false; } } elseif ( isset( $this->options['username'], $this->options['public_key'], $this->options['private_key'] ) && array_key_exists( 'password', $this->options ) ) { + if ( ! @ssh2_auth_pubkey_file( $this->link, $this->options['username'], $this->options['public_key'], $this->options['private_key'], $this->options['password'] ) ) { $this->errors->add( 'auth', sprintf( From 25496469b5cbde5afdbfb33cdd323692f922ee5c Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 9 Jun 2026 12:43:45 -0700 Subject: [PATCH 27/41] Ensure string is passed as passphrase arg --- src/wp-admin/includes/class-wp-filesystem-ssh2.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-admin/includes/class-wp-filesystem-ssh2.php b/src/wp-admin/includes/class-wp-filesystem-ssh2.php index fe759462b2386..ab6eeb9b081dc 100644 --- a/src/wp-admin/includes/class-wp-filesystem-ssh2.php +++ b/src/wp-admin/includes/class-wp-filesystem-ssh2.php @@ -199,7 +199,7 @@ public function connect() { return false; } } elseif ( isset( $this->options['username'], $this->options['public_key'], $this->options['private_key'] ) && array_key_exists( 'password', $this->options ) ) { - if ( ! @ssh2_auth_pubkey_file( $this->link, $this->options['username'], $this->options['public_key'], $this->options['private_key'], $this->options['password'] ) ) { + if ( ! @ssh2_auth_pubkey_file( $this->link, $this->options['username'], $this->options['public_key'], $this->options['private_key'], $this->options['password'] ?? '' ) ) { $this->errors->add( 'auth', sprintf( From c19bfa6897076a8afbdc1aaca85e3e80bb28c23c Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 9 Jun 2026 12:45:24 -0700 Subject: [PATCH 28/41] Fix type check for lastmodunix before passing into gmdate() Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/wp-admin/includes/class-wp-filesystem-direct.php | 4 ++-- src/wp-admin/includes/class-wp-filesystem-ssh2.php | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/wp-admin/includes/class-wp-filesystem-direct.php b/src/wp-admin/includes/class-wp-filesystem-direct.php index af453482a32ee..7ce13d8644a5b 100644 --- a/src/wp-admin/includes/class-wp-filesystem-direct.php +++ b/src/wp-admin/includes/class-wp-filesystem-direct.php @@ -693,8 +693,8 @@ public function dirlist( $path, $include_hidden = true, $recursive = false ) { $struc['group'] = $this->group( $path . $entry ); $struc['size'] = $this->size( $path . $entry ); $struc['lastmodunix'] = $this->mtime( $path . $entry ); - $struc['lastmod'] = is_string( $struc['lastmodunix'] ) ? gmdate( 'M j', $struc['lastmodunix'] ) : false; - $struc['time'] = is_string( $struc['lastmodunix'] ) ? gmdate( 'h:i:s', $struc['lastmodunix'] ) : false; + $struc['lastmod'] = is_int( $struc['lastmodunix'] ) ? gmdate( 'M j', $struc['lastmodunix'] ) : false; + $struc['time'] = is_int( $struc['lastmodunix'] ) ? gmdate( 'h:i:s', $struc['lastmodunix'] ) : false; $struc['type'] = $this->is_dir( $path . $entry ) ? 'd' : 'f'; if ( 'd' === $struc['type'] ) { diff --git a/src/wp-admin/includes/class-wp-filesystem-ssh2.php b/src/wp-admin/includes/class-wp-filesystem-ssh2.php index ab6eeb9b081dc..fa1b8abb72718 100644 --- a/src/wp-admin/includes/class-wp-filesystem-ssh2.php +++ b/src/wp-admin/includes/class-wp-filesystem-ssh2.php @@ -881,8 +881,8 @@ public function dirlist( $path, $include_hidden = true, $recursive = false ) { $struc['group'] = $this->group( $path . $entry ); $struc['size'] = $this->size( $path . $entry ); $struc['lastmodunix'] = $this->mtime( $path . $entry ); - $struc['lastmod'] = is_string( $struc['lastmodunix'] ) ? gmdate( 'M j', $struc['lastmodunix'] ) : false; - $struc['time'] = is_string( $struc['lastmodunix'] ) ? gmdate( 'h:i:s', $struc['lastmodunix'] ) : false; + $struc['lastmod'] = is_int( $struc['lastmodunix'] ) ? gmdate( 'M j', $struc['lastmodunix'] ) : false; + $struc['time'] = is_int( $struc['lastmodunix'] ) ? gmdate( 'h:i:s', $struc['lastmodunix'] ) : false; $struc['type'] = $this->is_dir( $path . $entry ) ? 'd' : 'f'; if ( 'd' === $struc['type'] ) { From ca7293dec183acc7b40a5fc0ddc1e2b8ec67ee40 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 9 Jun 2026 12:49:51 -0700 Subject: [PATCH 29/41] Document null as allowed value for $opt Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/wp-admin/includes/class-wp-filesystem-ftpsockets.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php b/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php index 31ecb4469f22d..7c2a51bb22fb6 100644 --- a/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php +++ b/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php @@ -53,7 +53,7 @@ class WP_Filesystem_ftpsockets extends WP_Filesystem_Base { * username: non-empty-string, * password: string, * port?: non-negative-int, - * } $opt + * }|null $opt */ public function __construct( $opt = null ) { $this->method = 'ftpsockets'; From 27672437bd0be08c2caaa9aacbe552062e2e2197 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 9 Jun 2026 12:51:35 -0700 Subject: [PATCH 30/41] Remove duplicated error checks Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/wp-admin/includes/class-wp-filesystem-ftpext.php | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/wp-admin/includes/class-wp-filesystem-ftpext.php b/src/wp-admin/includes/class-wp-filesystem-ftpext.php index 48bfd6f258bf5..a282e87d7efaf 100644 --- a/src/wp-admin/includes/class-wp-filesystem-ftpext.php +++ b/src/wp-admin/includes/class-wp-filesystem-ftpext.php @@ -109,16 +109,6 @@ public function __construct( $opt = null ) { $options['ssl'] = true; } - if ( ! isset( $options['hostname'] ) ) { - $this->errors->add( 'empty_hostname', __( 'FTP hostname is required' ) ); - } - if ( ! isset( $options['username'] ) ) { - $this->errors->add( 'empty_username', __( 'FTP username is required' ) ); - } - if ( ! isset( $options['password'] ) ) { - $this->errors->add( 'empty_password', __( 'FTP password is required' ) ); - } - if ( ! $this->errors->has_errors() ) { /** @var Options $options */ $this->options = $options; From 3f130d81642af96ea75a41174f231aed97bec49e Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 9 Jun 2026 12:53:03 -0700 Subject: [PATCH 31/41] Reorder link check to prevent orphaned temp file Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/wp-admin/includes/class-wp-filesystem-ftpext.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/wp-admin/includes/class-wp-filesystem-ftpext.php b/src/wp-admin/includes/class-wp-filesystem-ftpext.php index a282e87d7efaf..e949d9ca4a1b6 100644 --- a/src/wp-admin/includes/class-wp-filesystem-ftpext.php +++ b/src/wp-admin/includes/class-wp-filesystem-ftpext.php @@ -175,13 +175,13 @@ public function connect() { * or if the file couldn't be retrieved. */ public function get_contents( $file ) { - $tempfile = wp_tempnam( $file ); - $temphandle = fopen( $tempfile, 'w+' ); - if ( ! $this->link ) { return false; } + $tempfile = wp_tempnam( $file ); + $temphandle = fopen( $tempfile, 'w+' ); + if ( ! $temphandle ) { unlink( $tempfile ); return false; From 18ffaeebd8c769b63f4a176a5d3dfc77b761e6b7 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 9 Jun 2026 12:54:25 -0700 Subject: [PATCH 32/41] Fix check for dirlist() failure Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/wp-admin/includes/class-wp-filesystem-ftpsockets.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php b/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php index 7c2a51bb22fb6..5f3de3de85b75 100644 --- a/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php +++ b/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php @@ -716,7 +716,7 @@ public function dirlist( $path = '.', $include_hidden = true, $recursive = false /** @var array|false $list */ $list = $this->ftp->dirlist( $path ); - if ( ! $list || ! $this->exists( $path ) ) { + if ( false === $list || ! $this->exists( $path ) ) { reset_mbstring_encoding(); From e155b38a94c3410da0ac0660c3105bc62d026072 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 9 Jun 2026 12:55:29 -0700 Subject: [PATCH 33/41] Remove unused hostkey param Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/wp-admin/includes/class-wp-filesystem-ssh2.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/wp-admin/includes/class-wp-filesystem-ssh2.php b/src/wp-admin/includes/class-wp-filesystem-ssh2.php index fa1b8abb72718..b2ac614124bbb 100644 --- a/src/wp-admin/includes/class-wp-filesystem-ssh2.php +++ b/src/wp-admin/includes/class-wp-filesystem-ssh2.php @@ -94,7 +94,6 @@ class WP_Filesystem_SSH2 extends WP_Filesystem_Base { * port?: non-negative-int, * public_key?: non-empty-string, * private_key?: non-empty-string, - * hostkey?: array{ hostkey: non-empty-string }, * }|null $opt */ public function __construct( $opt = null ) { From 51bba93628503abe8bf9fdca9149faf164b43d0e Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 10 Jun 2026 15:32:42 -0700 Subject: [PATCH 34/41] Specify enum for FileListing type --- src/wp-admin/includes/class-wp-filesystem-base.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-admin/includes/class-wp-filesystem-base.php b/src/wp-admin/includes/class-wp-filesystem-base.php index b174ebc23d3be..fc124d7727fb8 100644 --- a/src/wp-admin/includes/class-wp-filesystem-base.php +++ b/src/wp-admin/includes/class-wp-filesystem-base.php @@ -22,7 +22,7 @@ * lastmodunix?: int|string|false, * lastmod?: string|false, * time: string|false, - * type: string, + * type: 'd'|'f'|'l', * islink?: bool, * isdir?: bool, * files?: mixed[]|false, // The mixed[] is actually FileListing[] but PHPStan does not support recursive or self-referencing array shapes. From 332866fcd6d4c7def62ee595e27c34e0942c1c33 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 10 Jun 2026 15:36:45 -0700 Subject: [PATCH 35/41] Filesystem: Restore working directory after is_dir() check in ftpsockets. Capture the current working directory before changing into the target path so the FTP session's working directory is restored, rather than capturing it afterward (which left the session in the target path and made is_dir() no longer side-effect-free). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../includes/class-wp-filesystem-ftpsockets.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php b/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php index 5f3de3de85b75..81b3eb69f8da0 100644 --- a/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php +++ b/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php @@ -520,12 +520,12 @@ public function is_file( $file ) { * @return bool Whether $path is a directory. */ public function is_dir( $path ) { - if ( $this->chdir( $path ) ) { - $cwd = $this->cwd(); - if ( ! $cwd ) { - return false; - } + $cwd = $this->cwd(); + if ( ! $cwd ) { + return false; + } + if ( $this->chdir( $path ) ) { $this->chdir( $cwd ); return true; } From c652a857294bf96009ce80e28d8e54f57fdb4d4c Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 10 Jun 2026 15:51:58 -0700 Subject: [PATCH 36/41] Filesystem: Always require a username in WP_Filesystem_SSH2. A username is mandatory for SSH authentication regardless of method: ssh2_auth_pubkey_file() takes a required username argument, and the SSH protocol authenticates a named user even when a key proves identity. Previously the constructor only required a username for password auth, so constructing with keys but no username produced no error, and connect() would then silently skip authentication entirely. Validate the username unconditionally so this surfaces a clear "SSH2 username is required" error, and update the option type shapes to mark username as required. With the username guaranteed, drop the now-redundant isset() guards for it in connect(), along with the password isset/array_key_exists checks, using null coalescing to satisfy the option type. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../includes/class-wp-filesystem-ssh2.php | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/wp-admin/includes/class-wp-filesystem-ssh2.php b/src/wp-admin/includes/class-wp-filesystem-ssh2.php index b2ac614124bbb..db3d9ccbddf09 100644 --- a/src/wp-admin/includes/class-wp-filesystem-ssh2.php +++ b/src/wp-admin/includes/class-wp-filesystem-ssh2.php @@ -35,7 +35,7 @@ * * @phpstan-type Options array{ * hostname: non-empty-string, - * username?: non-empty-string, + * username: non-empty-string, * password: string|null, * port: non-negative-int, * public_key?: non-empty-string, @@ -81,7 +81,7 @@ class WP_Filesystem_SSH2 extends WP_Filesystem_Base { * * @type string $hostname Required. SSH server hostname. * @type int $port Optional. SSH server port. Default 22. - * @type string $username Optional. SSH username. Required when not using keys. + * @type string $username Required. SSH username. * @type string $password Optional. SSH password. May be empty when using keys. * @type string $public_key Optional. Path to public key file for publickey authentication. * @type string $private_key Optional. Path to private key file for publickey authentication. @@ -89,7 +89,7 @@ class WP_Filesystem_SSH2 extends WP_Filesystem_Base { * } * @phpstan-param array{ * hostname: non-empty-string, - * username?: non-empty-string, + * username: non-empty-string, * password?: string, * port?: non-negative-int, * public_key?: non-empty-string, @@ -132,11 +132,12 @@ public function __construct( $opt = null ) { $options['hostkey'] = array( 'hostkey' => 'ssh-rsa,ssh-ed25519' ); $this->keys = true; - } elseif ( empty( $opt['username'] ) ) { - $this->errors->add( 'empty_username', __( 'SSH2 username is required' ) ); } - if ( ! empty( $opt['username'] ) ) { + // A username is always required, whether authenticating with a password or with keys. + if ( empty( $opt['username'] ) ) { + $this->errors->add( 'empty_username', __( 'SSH2 username is required' ) ); + } else { $options['username'] = $opt['username']; } @@ -184,8 +185,8 @@ public function connect() { return false; } - if ( ! $this->keys && isset( $this->options['username'], $this->options['password'] ) ) { - if ( ! @ssh2_auth_password( $this->link, $this->options['username'], $this->options['password'] ) ) { + if ( ! $this->keys ) { + if ( ! @ssh2_auth_password( $this->link, $this->options['username'], $this->options['password'] ?? '' ) ) { $this->errors->add( 'auth', sprintf( @@ -197,7 +198,7 @@ public function connect() { return false; } - } elseif ( isset( $this->options['username'], $this->options['public_key'], $this->options['private_key'] ) && array_key_exists( 'password', $this->options ) ) { + } elseif ( isset( $this->options['public_key'], $this->options['private_key'] ) ) { if ( ! @ssh2_auth_pubkey_file( $this->link, $this->options['username'], $this->options['public_key'], $this->options['private_key'], $this->options['password'] ?? '' ) ) { $this->errors->add( 'auth', From 40267e574da2162e8bcd5c3ed0b303c61ccb8cfc Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 10 Jun 2026 16:00:26 -0700 Subject: [PATCH 37/41] Filesystem: Guarantee an auth attempt in WP_Filesystem_SSH2::connect(). Restore the public-key authentication branch to a plain else. Since $this->keys is set true only when both public_key and private_key are provided, the previous `elseif ( isset( ... ) )` was always true when reached, and the unreachable third path meant connect() could fall through to ssh2_sftp() with no authentication attempted at all. Use null coalescing on the key paths to satisfy the option type shape, which marks public_key and private_key as optional; the empty-string fallbacks cannot occur at runtime, and would fail authentication safely rather than skipping it if they ever did. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/wp-admin/includes/class-wp-filesystem-ssh2.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wp-admin/includes/class-wp-filesystem-ssh2.php b/src/wp-admin/includes/class-wp-filesystem-ssh2.php index db3d9ccbddf09..46af0317ff840 100644 --- a/src/wp-admin/includes/class-wp-filesystem-ssh2.php +++ b/src/wp-admin/includes/class-wp-filesystem-ssh2.php @@ -198,8 +198,8 @@ public function connect() { return false; } - } elseif ( isset( $this->options['public_key'], $this->options['private_key'] ) ) { - if ( ! @ssh2_auth_pubkey_file( $this->link, $this->options['username'], $this->options['public_key'], $this->options['private_key'], $this->options['password'] ?? '' ) ) { + } else { + if ( ! @ssh2_auth_pubkey_file( $this->link, $this->options['username'], $this->options['public_key'] ?? '', $this->options['private_key'] ?? '', $this->options['password'] ?? '' ) ) { $this->errors->add( 'auth', sprintf( From 3b369db0eb834f0902be9617c96159ecd2b09325 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 10 Jun 2026 16:03:29 -0700 Subject: [PATCH 38/41] Filesystem: Preserve dirlist() empty-and-missing semantics in ftpsockets. Handling the false return of the underlying ftp->dirlist() is correct, but combining the path-existence check with OR changed behavior: a populated listing was discarded whenever exists() disagreed (it probes via a separate nlist() call with documented quirks). Restore the original semantics of only erroring when the listing is empty AND the path does not exist, while still treating an outright false as a failure. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/wp-admin/includes/class-wp-filesystem-ftpsockets.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php b/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php index 81b3eb69f8da0..a08b600374f05 100644 --- a/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php +++ b/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php @@ -716,7 +716,7 @@ public function dirlist( $path = '.', $include_hidden = true, $recursive = false /** @var array|false $list */ $list = $this->ftp->dirlist( $path ); - if ( false === $list || ! $this->exists( $path ) ) { + if ( false === $list || ( empty( $list ) && ! $this->exists( $path ) ) ) { reset_mbstring_encoding(); From ab662125eb0e09a079ec34f9e46cda9a341ef5a5 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 10 Jun 2026 16:05:07 -0700 Subject: [PATCH 39/41] Filesystem: Restore root fallback in search_for_folder() when cwd() fails. The new is_string() guard around the cwd() result left $base at its incoming '.'/'' value when cwd() returns false (the FTP transports do this on failure), changing the directory search root. Trunk derived $base from trailingslashit( $this->cwd() ), which yielded '/' for a false cwd(). Preserve that behavior by falling back to '/' explicitly. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/wp-admin/includes/class-wp-filesystem-base.php | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/wp-admin/includes/class-wp-filesystem-base.php b/src/wp-admin/includes/class-wp-filesystem-base.php index fc124d7727fb8..e7f924f2e87be 100644 --- a/src/wp-admin/includes/class-wp-filesystem-base.php +++ b/src/wp-admin/includes/class-wp-filesystem-base.php @@ -280,10 +280,8 @@ public function find_folder( $folder ) { */ public function search_for_folder( $folder, $base = '.', $loop = false ) { if ( empty( $base ) || '.' === $base ) { - $cwd = $this->cwd(); - if ( is_string( $cwd ) ) { - $base = trailingslashit( $cwd ); - } + $cwd = $this->cwd(); + $base = is_string( $cwd ) ? trailingslashit( $cwd ) : '/'; } $folder = untrailingslashit( $folder ); From 4921788f40360da7fead337139d54836f21abd65 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 10 Jun 2026 16:11:08 -0700 Subject: [PATCH 40/41] Filesystem: Bail early from connect() when construction recorded errors. The FTP/SSH2 subclasses now assign $this->options only when the constructor records no errors, so a failed construction leaves $this->options null rather than the base class's array() default. Calling connect() directly on such an object (bypassing the has_errors() check WP_Filesystem() performs) would then access an offset on null. Guard each connect() with an early return when errors are present, matching the check in WP_Filesystem(). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/wp-admin/includes/class-wp-filesystem-ftpext.php | 4 ++++ src/wp-admin/includes/class-wp-filesystem-ftpsockets.php | 4 ++++ src/wp-admin/includes/class-wp-filesystem-ssh2.php | 9 ++++++--- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/wp-admin/includes/class-wp-filesystem-ftpext.php b/src/wp-admin/includes/class-wp-filesystem-ftpext.php index e949d9ca4a1b6..b27df38b38d1d 100644 --- a/src/wp-admin/includes/class-wp-filesystem-ftpext.php +++ b/src/wp-admin/includes/class-wp-filesystem-ftpext.php @@ -123,6 +123,10 @@ public function __construct( $opt = null ) { * @return bool True on success, false on failure. */ public function connect() { + if ( $this->errors->has_errors() ) { + return false; + } + if ( isset( $this->options['ssl'] ) && $this->options['ssl'] && function_exists( 'ftp_ssl_connect' ) ) { $this->link = @ftp_ssl_connect( $this->options['hostname'], $this->options['port'], FS_CONNECT_TIMEOUT ); } else { diff --git a/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php b/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php index a08b600374f05..4abd1643b965a 100644 --- a/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php +++ b/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php @@ -110,6 +110,10 @@ public function __construct( $opt = null ) { * @return bool True on success, false on failure. */ public function connect() { + if ( $this->errors->has_errors() ) { + return false; + } + if ( ! $this->ftp ) { return false; } diff --git a/src/wp-admin/includes/class-wp-filesystem-ssh2.php b/src/wp-admin/includes/class-wp-filesystem-ssh2.php index 46af0317ff840..c01ab6f465d5b 100644 --- a/src/wp-admin/includes/class-wp-filesystem-ssh2.php +++ b/src/wp-admin/includes/class-wp-filesystem-ssh2.php @@ -80,18 +80,17 @@ class WP_Filesystem_SSH2 extends WP_Filesystem_Base { * Array of connection options. * * @type string $hostname Required. SSH server hostname. - * @type int $port Optional. SSH server port. Default 22. * @type string $username Required. SSH username. + * @type int $port Optional. SSH server port. Default 22. * @type string $password Optional. SSH password. May be empty when using keys. * @type string $public_key Optional. Path to public key file for publickey authentication. * @type string $private_key Optional. Path to private key file for publickey authentication. - * @type array $hostkey Optional. Hostkey options passed to ssh2_connect. * } * @phpstan-param array{ * hostname: non-empty-string, * username: non-empty-string, - * password?: string, * port?: non-negative-int, + * password?: string, * public_key?: non-empty-string, * private_key?: non-empty-string, * }|null $opt @@ -166,6 +165,10 @@ public function __construct( $opt = null ) { * @return bool True on success, false on failure. */ public function connect() { + if ( $this->errors->has_errors() ) { + return false; + } + if ( ! isset( $this->options['hostkey'] ) ) { $this->link = @ssh2_connect( $this->options['hostname'], $this->options['port'] ); } else { From e5502fbb6525158c9122dbd35f6add2443da4d23 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 10 Jun 2026 16:43:16 -0700 Subject: [PATCH 41/41] Filesystem: Widen FileListing time to int|string|false. The FileListing shape and the dirlist() @type docs declared time as string|false, but the FTP transports build it from mktime()/strtotime() in parselisting(), i.e. an integer Unix timestamp, while Direct and SSH2 format it as a gmdate() string. Widen the shared shape to int|string|false and note in the docs that FTP transports return a timestamp, so the type reflects the actual data across transports. Also harden the ftpsockets dirlist() consumer to guard the untyped PemFTP ftp::dirlist() return with is_array() rather than a strict false check, so an unexpected non-array return is handled instead of trusted. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/wp-admin/includes/class-wp-filesystem-base.php | 4 ++-- src/wp-admin/includes/class-wp-filesystem-ftpext.php | 2 +- src/wp-admin/includes/class-wp-filesystem-ftpsockets.php | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/wp-admin/includes/class-wp-filesystem-base.php b/src/wp-admin/includes/class-wp-filesystem-base.php index e7f924f2e87be..4972c8421cf11 100644 --- a/src/wp-admin/includes/class-wp-filesystem-base.php +++ b/src/wp-admin/includes/class-wp-filesystem-base.php @@ -21,7 +21,7 @@ * size: int|string|false, * lastmodunix?: int|string|false, * lastmod?: string|false, - * time: string|false, + * time: int|string|false, * type: 'd'|'f'|'l', * islink?: bool, * isdir?: bool, @@ -874,7 +874,7 @@ public function rmdir( $path, $recursive = false ) { * False if not available. * @type string|false $lastmod Last modified month (3 letters) and day (without leading 0), or * false if not available. - * @type string|false $time Last modified time, or false if not available. + * @type int|string|false $time Last modified time. A Unix timestamp on FTP transports, or false if not available. * @type string $type Type of resource. 'f' for file, 'd' for directory, 'l' for link. * @type array|false $files If a directory and `$recursive` is true, contains another array of * files. False if unable to list directory contents. diff --git a/src/wp-admin/includes/class-wp-filesystem-ftpext.php b/src/wp-admin/includes/class-wp-filesystem-ftpext.php index b27df38b38d1d..d98f1903d86e8 100644 --- a/src/wp-admin/includes/class-wp-filesystem-ftpext.php +++ b/src/wp-admin/includes/class-wp-filesystem-ftpext.php @@ -808,7 +808,7 @@ public function parselisting( $line ) { * False if not available. * @type string|false $lastmod Last modified month (3 letters) and day (without leading 0), or * false if not available. - * @type string|false $time Last modified time, or false if not available. + * @type int|string|false $time Last modified time as a Unix timestamp, or false if not available. * @type string $type Type of resource. 'f' for file, 'd' for directory, 'l' for link. * @type array|false $files If a directory and `$recursive` is true, contains another array of * files. False if unable to list directory contents. diff --git a/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php b/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php index 4abd1643b965a..260c3fade15d5 100644 --- a/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php +++ b/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php @@ -699,7 +699,7 @@ public function rmdir( $path, $recursive = false ) { * False if not available. * @type string|false $lastmod Last modified month (3 letters) and day (without leading 0), or * false if not available. - * @type string|false $time Last modified time, or false if not available. + * @type int|string|false $time Last modified time as a Unix timestamp, or false if not available. * @type string $type Type of resource. 'f' for file, 'd' for directory, 'l' for link. * @type array|false $files If a directory and `$recursive` is true, contains another array of * files. False if unable to list directory contents. @@ -720,7 +720,7 @@ public function dirlist( $path = '.', $include_hidden = true, $recursive = false /** @var array|false $list */ $list = $this->ftp->dirlist( $path ); - if ( false === $list || ( empty( $list ) && ! $this->exists( $path ) ) ) { + if ( ! is_array( $list ) || ( empty( $list ) && ! $this->exists( $path ) ) ) { reset_mbstring_encoding();