Index: trunk/src/wp-admin/includes/class-wp-filesystem-ssh2.php
===================================================================
--- trunk/src/wp-admin/includes/class-wp-filesystem-ssh2.php	(revision 61699)
+++ 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';
 
