Index: /trunk/composer.json
===================================================================
--- /trunk/composer.json	(revision 62636)
+++ /trunk/composer.json	(revision 62637)
@@ -18,5 +18,7 @@
 	"suggest": {
 		"ext-dom": "*",
-		"ext-mysqli": "*"
+		"ext-ftp": "*",
+		"ext-mysqli": "*",
+		"ext-ssh2": "*"
 	},
 	"require-dev": {
Index: /trunk/src/wp-admin/includes/class-wp-filesystem-base.php
===================================================================
--- /trunk/src/wp-admin/includes/class-wp-filesystem-base.php	(revision 62636)
+++ /trunk/src/wp-admin/includes/class-wp-filesystem-base.php	(revision 62637)
@@ -11,4 +11,21 @@
  *
  * @since 2.5.0
+ *
+ * @phpstan-type FileListing array{
+ *     name: string,
+ *     perms?: string,
+ *     permsn?: string,
+ *     number?: int|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,
+ *     time: int|string|false,
+ *     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.
+ * }
  */
 #[AllowDynamicProperties]
@@ -27,5 +44,5 @@
 	 *
 	 * @since 2.7.0
-	 * @var array
+	 * @var array<string, string>
 	 */
 	public $cache = array();
@@ -45,4 +62,5 @@
 
 	/**
+	 * @var array<string, mixed>
 	 */
 	public $options = array();
@@ -53,5 +71,5 @@
 	 * @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() {
@@ -74,5 +92,5 @@
 	 * @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() {
@@ -85,5 +103,5 @@
 	 * @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() {
@@ -98,8 +116,8 @@
 	 * @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.
@@ -116,5 +134,5 @@
 	 * @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() {
@@ -135,5 +153,5 @@
 	 * @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 ) {
@@ -156,5 +174,5 @@
 	 * @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 ) {
@@ -195,5 +213,7 @@
 
 				if ( $folder === $dir ) {
-					return trailingslashit( constant( $constant ) );
+					/** @var string $constant_value */
+					$constant_value = constant( $constant );
+					return trailingslashit( $constant_value );
 				}
 			}
@@ -206,5 +226,7 @@
 
 				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 );
 
@@ -222,5 +244,5 @@
 		}
 
-		$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.
 
@@ -259,5 +281,6 @@
 	public function search_for_folder( $folder, $base = '.', $loop = false ) {
 		if ( empty( $base ) || '.' === $base ) {
-			$base = trailingslashit( $this->cwd() );
+			$cwd  = $this->cwd();
+			$base = is_string( $cwd ) ? trailingslashit( $cwd ) : '/';
 		}
 
@@ -421,5 +444,5 @@
 		$realmode = '';
 		$legal    = array( '', 'w', 'r', 'x', '-' );
-		$attarray = preg_split( '//', $mode );
+		$attarray = (array) preg_split( '//', $mode );
 
 		for ( $i = 0, $c = count( $attarray ); $i < $c; $i++ ) {
@@ -441,7 +464,7 @@
 
 		$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;
@@ -509,5 +532,5 @@
 	 *
 	 * @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 ) {
@@ -852,5 +875,5 @@
 	 *         @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
@@ -858,4 +881,5 @@
 	 *     }
 	 * }
+	 * @phpstan-return array<string, FileListing>|false
 	 */
 	public function dirlist( $path, $include_hidden = true, $recursive = false ) {
Index: /trunk/src/wp-admin/includes/class-wp-filesystem-direct.php
===================================================================
--- /trunk/src/wp-admin/includes/class-wp-filesystem-direct.php	(revision 62636)
+++ /trunk/src/wp-admin/includes/class-wp-filesystem-direct.php	(revision 62637)
@@ -13,4 +13,5 @@
  *
  * @see WP_Filesystem_Base
+ * @phpstan-import-type FileListing from WP_Filesystem_Base
  */
 class WP_Filesystem_Direct extends WP_Filesystem_Base {
@@ -24,4 +25,6 @@
 	 */
 	public function __construct( $arg ) {
+		// The $arg parameter is required for signature parity with the other transports, but is unused here.
+		unset( $arg );
 		$this->method = 'direct';
 		$this->errors = new WP_Error();
@@ -46,5 +49,5 @@
 	 *
 	 * @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 ) {
@@ -139,4 +142,7 @@
 		$file     = trailingslashit( $file );
 		$filelist = $this->dirlist( $file );
+		if ( false === $filelist ) {
+			return false;
+		}
 
 		foreach ( $filelist as $file_listing ) {
@@ -227,4 +233,7 @@
 		// Is a directory, and we want recursive.
 		$filelist = $this->dirlist( $file );
+		if ( false === $filelist ) {
+			return false;
+		}
 
 		foreach ( $filelist as $file_listing ) {
@@ -241,5 +250,5 @@
 	 *
 	 * @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 ) {
@@ -286,5 +295,5 @@
 	 *
 	 * @param string $file Path to the file.
-	 * @return string|false The group on success, false on failure.
+	 * @return string|int<1, max>|false Group name on success, or GID of the file's group if not available; false on failure.
 	 */
 	public function group( $file ) {
@@ -640,4 +649,5 @@
 	 *     }
 	 * }
+	 * @phpstan-return array<string, FileListing>|false
 	 */
 	public function dirlist( $path, $include_hidden = true, $recursive = false ) {
@@ -685,6 +695,6 @@
 			$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_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';
 
Index: /trunk/src/wp-admin/includes/class-wp-filesystem-ftpext.php
===================================================================
--- /trunk/src/wp-admin/includes/class-wp-filesystem-ftpext.php	(revision 62636)
+++ /trunk/src/wp-admin/includes/class-wp-filesystem-ftpext.php	(revision 62637)
@@ -13,4 +13,12 @@
  *
  * @see WP_Filesystem_Base
+ * @phpstan-type Options array{
+ *     hostname: string,
+ *     username: string,
+ *     password: string,
+ *     port: non-negative-int,
+ *     ssl: bool,
+ * }
+ * @phpstan-import-type FileListing from WP_Filesystem_Base
  */
 class WP_Filesystem_FTPext extends WP_Filesystem_Base {
@@ -23,13 +31,42 @@
 
 	/**
+	 * @since 7.1.0
+	 * @var array
+	 * @phpstan-var Options
+	 */
+	public $options;
+
+	/**
 	 * Constructor.
 	 *
 	 * @since 2.5.0
 	 *
-	 * @param array $opt
-	 */
-	public function __construct( $opt = '' ) {
-		$this->method = 'ftpext';
-		$this->errors = new WP_Error();
+	 * @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.
+	 *     @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 = null ) {
+		$this->method  = 'ftpext';
+		$this->errors  = new WP_Error();
+		$this->options = array(
+			'port'     => 21,
+			'hostname' => '',
+			'username' => '',
+			'password' => '',
+			'ssl'      => false,
+		);
 
 		// Check if possible to use ftp functions.
@@ -44,7 +81,9 @@
 		}
 
-		if ( empty( $opt['port'] ) ) {
-			$this->options['port'] = 21;
-		} else {
+		if ( ! is_array( $opt ) ) {
+			$opt = array();
+		}
+
+		if ( ! empty( $opt['port'] ) ) {
 			$this->options['port'] = $opt['port'];
 		}
@@ -69,6 +108,4 @@
 		}
 
-		$this->options['ssl'] = false;
-
 		if ( isset( $opt['connection_type'] ) && 'ftps' === $opt['connection_type'] ) {
 			$this->options['ssl'] = true;
@@ -84,4 +121,13 @@
 	 */
 	public function connect() {
+		/*
+		 * Bail if the constructor recorded a configuration error. Connection and
+		 * authentication errors are excluded so that a failed connection attempt
+		 * can be retried on the same instance.
+		 */
+		if ( $this->errors->has_errors() && ! array_intersect( array( 'connect', 'auth' ), $this->errors->get_error_codes() ) ) {
+			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 );
@@ -136,4 +182,8 @@
 	 */
 	public function get_contents( $file ) {
+		if ( ! $this->link ) {
+			return false;
+		}
+
 		$tempfile   = wp_tempnam( $file );
 		$temphandle = fopen( $tempfile, 'w+' );
@@ -169,8 +219,12 @@
 	 *
 	 * @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;
 	}
 
@@ -295,5 +349,5 @@
 	 *
 	 * @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 ) {
@@ -323,5 +377,5 @@
 	 *
 	 * @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 ) {
@@ -466,6 +520,14 @@
 	 */
 	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 ) {
@@ -623,4 +685,5 @@
 	 *                                     False if unable to list directory contents.
 	 * }
+	 * @phpstan-return FileListing|''
 	 */
 	public function parselisting( $line ) {
@@ -648,13 +711,7 @@
 			}
 
-			$b['size']   = $lucifer[7];
-			$b['month']  = $lucifer[1];
-			$b['day']    = $lucifer[2];
-			$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['am/pm']  = $lucifer[6];
-			$b['name']   = $lucifer[8];
+			$b['size'] = $lucifer[7];
+			$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['name'] = $lucifer[8];
 		} elseif ( ! $is_windows ) {
 			$lucifer = preg_split( '/[ ]/', $line, 9, PREG_SPLIT_NO_EMPTY );
@@ -687,24 +744,24 @@
 
 				if ( 8 === $lcount ) {
-					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'] );
+					sscanf( $lucifer[5], '%d-%d-%d', $year, $month, $day );
+					sscanf( $lucifer[6], '%d:%d', $hour, $minute );
+
+					$b['time'] = mktime( (int) $hour, (int) $minute, 0, (int) $month, (int) $day, (int) $year );
 					$b['name'] = $lucifer[7];
 				} else {
-					$b['month'] = $lucifer[5];
-					$b['day']   = $lucifer[6];
+					$month = $lucifer[5];
+					$day   = $lucifer[6];
 
 					if ( preg_match( '/([0-9]{2}):([0-9]{2})/', $lucifer[7], $l2 ) ) {
-						$b['year']   = gmdate( 'Y' );
-						$b['hour']   = $l2[1];
-						$b['minute'] = $l2[2];
+						$year   = gmdate( 'Y' );
+						$hour   = $l2[1];
+						$minute = $l2[2];
 					} else {
-						$b['year']   = $lucifer[7];
-						$b['hour']   = 0;
-						$b['minute'] = 0;
+						$year   = $lucifer[7];
+						$hour   = 0;
+						$minute = 0;
 					}
 
-					$b['time'] = strtotime( sprintf( '%d %s %d %02d:%02d', $b['day'], $b['month'], $b['year'], $b['hour'], $b['minute'] ) );
+					$b['time'] = strtotime( sprintf( '%d %s %d %02d:%02d', $day, $month, $year, $hour, $minute ) );
 					$b['name'] = $lucifer[8];
 				}
@@ -714,8 +771,8 @@
 		// 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'] );
-		}
-
-		return $b;
+			$b['name'] = (string) preg_replace( '/(\s*->\s*.*)$/', '', $b['name'] );
+		}
+
+		return $b ?? '';
 	}
 
@@ -748,5 +805,5 @@
 	 *         @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
@@ -754,6 +811,11 @@
 	 *     }
 	 * }
+	 * @phpstan-return array<string, 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 );
@@ -764,4 +826,7 @@
 
 		$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.
@@ -769,4 +834,5 @@
 		}
 
+		/** @var string[]|false $list */
 		$list = ftp_rawlist( $this->link, '-a', false );
 
Index: /trunk/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php
===================================================================
--- /trunk/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php	(revision 62636)
+++ /trunk/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php	(revision 62637)
@@ -13,4 +13,11 @@
  *
  * @see WP_Filesystem_Base
+ * @phpstan-type Options array{
+ *     hostname: string,
+ *     username: string,
+ *     password: string,
+ *     port: non-negative-int,
+ * }
+ * @phpstan-import-type FileListing from WP_Filesystem_Base
  */
 class WP_Filesystem_ftpsockets extends WP_Filesystem_Base {
@@ -23,13 +30,39 @@
 
 	/**
+	 * @since 7.1.0
+	 * @var array
+	 * @phpstan-var Options
+	 */
+	public $options;
+
+	/**
 	 * Constructor.
 	 *
 	 * @since 2.5.0
 	 *
-	 * @param array $opt
-	 */
-	public function __construct( $opt = '' ) {
-		$this->method = 'ftpsockets';
-		$this->errors = new WP_Error();
+	 * @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.
+	 *     @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,
+	 * }|null $opt
+	 */
+	public function __construct( $opt = null ) {
+		$this->method  = 'ftpsockets';
+		$this->errors  = new WP_Error();
+		$this->options = array(
+			'port'     => 21,
+			'hostname' => '',
+			'username' => '',
+			'password' => '',
+		);
 
 		// Check if possible to use ftp functions.
@@ -40,7 +73,9 @@
 		$this->ftp = new ftp();
 
-		if ( empty( $opt['port'] ) ) {
-			$this->options['port'] = 21;
-		} else {
+		if ( ! is_array( $opt ) ) {
+			$opt = array();
+		}
+
+		if ( ! empty( $opt['port'] ) ) {
 			$this->options['port'] = (int) $opt['port'];
 		}
@@ -74,4 +109,13 @@
 	 */
 	public function connect() {
+		/*
+		 * Bail if the constructor recorded a configuration error. Connection and
+		 * authentication errors are excluded so that a failed connection attempt
+		 * can be retried on the same instance.
+		 */
+		if ( $this->errors->has_errors() && ! array_intersect( array( 'connect', 'auth' ), $this->errors->get_error_codes() ) ) {
+			return false;
+		}
+
 		if ( ! $this->ftp ) {
 			return false;
@@ -180,8 +224,12 @@
 	 *
 	 * @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;
 	}
 
@@ -222,5 +270,5 @@
 		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();
@@ -243,4 +291,7 @@
 	public function cwd() {
 		$cwd = $this->ftp->pwd();
+		if ( ! is_string( $cwd ) ) {
+			return false;
+		}
 
 		if ( $cwd ) {
@@ -260,5 +311,5 @@
 	 */
 	public function chdir( $dir ) {
-		return $this->ftp->chdir( $dir );
+		return (bool) $this->ftp->chdir( $dir );
 	}
 
@@ -296,5 +347,5 @@
 
 		// chmod the file or directory.
-		return $this->ftp->chmod( $file, $mode );
+		return (bool) $this->ftp->chmod( $file, $mode );
 	}
 
@@ -305,5 +356,5 @@
 	 *
 	 * @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 ) {
@@ -333,5 +384,5 @@
 	 *
 	 * @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 ) {
@@ -387,5 +438,5 @@
 	 */
 	public function move( $source, $destination, $overwrite = false ) {
-		return $this->ftp->rename( $source, $destination );
+		return (bool) $this->ftp->rename( $source, $destination );
 	}
 
@@ -408,12 +459,12 @@
 
 		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 $this->ftp->mdel( $file );
+			return (bool) $this->ftp->rmdir( $file );
+		}
+
+		return (bool) $this->ftp->mdel( $file );
 	}
 
@@ -478,4 +529,7 @@
 	public function is_dir( $path ) {
 		$cwd = $this->cwd();
+		if ( ! $cwd ) {
+			return false;
+		}
 
 		if ( $this->chdir( $path ) ) {
@@ -532,5 +586,9 @@
 	 */
 	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;
 	}
 
@@ -544,5 +602,9 @@
 	 */
 	public function size( $file ) {
-		return $this->ftp->filesize( $file );
+		$size = $this->ftp->filesize( $file );
+		if ( false === $size ) {
+			return false;
+		}
+		return (int) $size;
 	}
 
@@ -641,5 +703,5 @@
 	 *         @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
@@ -647,4 +709,5 @@
 	 *     }
 	 * }
+	 * @phpstan-return array<string, FileListing>|false
 	 */
 	public function dirlist( $path = '.', $include_hidden = true, $recursive = false ) {
@@ -658,7 +721,8 @@
 		mbstring_binary_safe_encoding();
 
+		/** @var array<string, FileListing>|false $list */
 		$list = $this->ftp->dirlist( $path );
 
-		if ( empty( $list ) && ! $this->exists( $path ) ) {
+		if ( ! is_array( $list ) || ( empty( $list ) && ! $this->exists( $path ) ) ) {
 
 			reset_mbstring_encoding();
@@ -693,10 +757,12 @@
 
 			// 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;
Index: /trunk/src/wp-admin/includes/class-wp-filesystem-ssh2.php
===================================================================
--- /trunk/src/wp-admin/includes/class-wp-filesystem-ssh2.php	(revision 62636)
+++ /trunk/src/wp-admin/includes/class-wp-filesystem-ssh2.php	(revision 62637)
@@ -33,4 +33,15 @@
  * @package WordPress
  * @subpackage Filesystem
+ *
+ * @phpstan-type Options array{
+ *     hostname: string,
+ *     username: string,
+ *     password: string|null,
+ *     port: non-negative-int,
+ *     public_key?: non-empty-string,
+ *     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 {
@@ -38,5 +49,5 @@
 	/**
 	 * @since 2.7.0
-	 * @var resource
+	 * @var resource|false
 	 */
 	public $link = false;
@@ -44,5 +55,5 @@
 	/**
 	 * @since 2.7.0
-	 * @var resource
+	 * @var resource|false
 	 */
 	public $sftp_link;
@@ -55,13 +66,43 @@
 
 	/**
+	 * @since 7.1.0
+	 * @var array
+	 * @phpstan-var Options
+	 */
+	public $options;
+
+	/**
 	 * Constructor.
 	 *
 	 * @since 2.7.0
 	 *
-	 * @param array $opt
-	 */
-	public function __construct( $opt = '' ) {
-		$this->method = 'ssh2';
-		$this->errors = new WP_Error();
+	 * @param array $opt {
+	 *     Array of connection options.
+	 *
+	 *     @type string $hostname    Required. SSH server hostname.
+	 *     @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.
+	 * }
+	 * @phpstan-param array{
+	 *     hostname: non-empty-string,
+	 *     username: non-empty-string,
+	 *     port?: non-negative-int,
+	 *     password?: string,
+	 *     public_key?: non-empty-string,
+	 *     private_key?: non-empty-string,
+	 * }|null $opt
+	 */
+	public function __construct( $opt = null ) {
+		$this->method  = 'ssh2';
+		$this->errors  = new WP_Error();
+		$this->options = array(
+			'port'     => 22,
+			'hostname' => '',
+			'username' => '',
+			'password' => null,
+		);
 
 		// Check if possible to use ssh2 functions.
@@ -71,8 +112,10 @@
 		}
 
+		if ( ! is_array( $opt ) ) {
+			$opt = array();
+		}
+
 		// Set defaults:
-		if ( empty( $opt['port'] ) ) {
-			$this->options['port'] = 22;
-		} else {
+		if ( ! empty( $opt['port'] ) ) {
 			$this->options['port'] = $opt['port'];
 		}
@@ -92,21 +135,18 @@
 
 			$this->keys = true;
-		} elseif ( 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' ) );
-		}
-
-		if ( ! empty( $opt['username'] ) ) {
+		} else {
 			$this->options['username'] = $opt['username'];
 		}
 
-		if ( empty( $opt['password'] ) ) {
+		if ( ! empty( $opt['password'] ) ) {
+			$this->options['password'] = $opt['password'];
+		} elseif ( ! $this->keys ) {
 			// Password can be blank if we are using keys.
-			if ( ! $this->keys ) {
-				$this->errors->add( 'empty_password', __( 'SSH2 password is required' ) );
-			} else {
-				$this->options['password'] = null;
-			}
-		} else {
-			$this->options['password'] = $opt['password'];
+			$this->errors->add( 'empty_password', __( 'SSH2 password is required' ) );
 		}
 	}
@@ -120,5 +160,14 @@
 	 */
 	public function connect() {
-		if ( ! $this->keys ) {
+		/*
+		 * Bail if the constructor recorded a configuration error. Connection and
+		 * authentication errors are excluded so that a failed connection attempt
+		 * can be retried on the same instance.
+		 */
+		if ( $this->errors->has_errors() && ! array_intersect( array( 'connect', 'auth' ), $this->errors->get_error_codes() ) ) {
+			return false;
+		}
+
+		if ( ! isset( $this->options['hostkey'] ) ) {
 			$this->link = @ssh2_connect( $this->options['hostname'], $this->options['port'] );
 		} else {
@@ -140,5 +189,5 @@
 
 		if ( ! $this->keys ) {
-			if ( ! @ssh2_auth_password( $this->link, $this->options['username'], $this->options['password'] ) ) {
+			if ( ! @ssh2_auth_password( $this->link, $this->options['username'], $this->options['password'] ?? '' ) ) {
 				$this->errors->add(
 					'auth',
@@ -153,5 +202,5 @@
 			}
 		} else {
-			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',
@@ -213,4 +262,6 @@
 	 * @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 ) {
@@ -265,5 +316,5 @@
 	 *
 	 * @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 ) {
@@ -302,11 +353,15 @@
 	 */
 	public function cwd() {
+		if ( ! $this->sftp_link ) {
+			return false;
+		}
+
 		$cwd = ssh2_sftp_realpath( $this->sftp_link, '.' );
 
-		if ( $cwd ) {
-			$cwd = trailingslashit( trim( $cwd ) );
-		}
-
-		return $cwd;
+		if ( ! is_string( $cwd ) ) {
+			return false;
+		}
+
+		return trailingslashit( trim( $cwd ) );
 	}
 
@@ -340,8 +395,8 @@
 
 		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 -R %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( (string) $group ), escapeshellarg( $file ) ), true );
 	}
 
@@ -397,8 +452,8 @@
 
 		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 -R %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( (string) $owner ), escapeshellarg( $file ) ), true );
 	}
 
@@ -409,5 +464,5 @@
 	 *
 	 * @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 ) {
@@ -437,8 +492,12 @@
 	 *
 	 * @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 );
 	}
 
@@ -449,5 +508,5 @@
 	 *
 	 * @param string $file Path to the file.
-	 * @return string|false The group on success, false on failure.
+	 * @return string|int<1, max>|false Group name on success, or GID of the file's group if not available; false on failure.
 	 */
 	public function group( $file ) {
@@ -527,4 +586,7 @@
 		}
 
+		if ( ! $this->sftp_link ) {
+			return false;
+		}
 		return ssh2_sftp_rename( $this->sftp_link, $source, $destination );
 	}
@@ -543,4 +605,7 @@
 	 */
 	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 );
@@ -693,4 +758,7 @@
 	 */
 	public function mkdir( $path, $chmod = false, $chown = false, $chgrp = false ) {
+		if ( ! $this->sftp_link ) {
+			return false;
+		}
 		$path = untrailingslashit( $path );
 
@@ -769,4 +837,5 @@
 	 *     }
 	 * }
+	 * @phpstan-return array<string, FileListing>|false
 	 */
 	public function dirlist( $path, $include_hidden = true, $recursive = false ) {
@@ -814,6 +883,6 @@
 			$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_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';
 
