diff --git a/phpseclib/Net/SSH2.php b/phpseclib/Net/SSH2.php index 9a0133fd..f680a3b3 100644 --- a/phpseclib/Net/SSH2.php +++ b/phpseclib/Net/SSH2.php @@ -646,6 +646,14 @@ class SSH2 */ protected $channel_status = []; + /** + * The identifier of the interactive channel which was opened most recently + * + * @see self::getInteractiveChannelId() + * @var int + */ + private $channel_id_last_interactive = 0; + /** * Packet Size * @@ -837,20 +845,6 @@ class SSH2 */ private $request_pty = false; - /** - * Flag set while exec() is running when using enablePTY() - * - * @var bool - */ - private $in_request_pty_exec = false; - - /** - * Flag set after startSubsystem() is called - * - * @var bool - */ - private $in_subsystem; - /** * Contents of stdError * @@ -2729,7 +2723,7 @@ class SSH2 return false; } - if ($this->in_request_pty_exec) { + if ($this->isPTYOpen()) { throw new \RuntimeException('If you want to run multiple exec()\'s you will need to disable (and re-enable if appropriate) a PTY for each one.'); } @@ -2779,8 +2773,6 @@ class SSH2 $this->disconnect_helper(NET_SSH2_DISCONNECT_BY_APPLICATION); throw new \RuntimeException('Unable to request pseudo-terminal'); } - - $this->in_request_pty_exec = true; } // sending a pty-req SSH_MSG_CHANNEL_REQUEST message is unnecessary and, in fact, in most cases, slows things @@ -2810,7 +2802,8 @@ class SSH2 $this->channel_status[self::CHANNEL_EXEC] = NET_SSH2_MSG_CHANNEL_DATA; - if ($this->in_request_pty_exec) { + if ($this->request_pty === true) { + $this->channel_id_last_interactive = self::CHANNEL_EXEC; return true; } @@ -2838,16 +2831,25 @@ class SSH2 /** * Creates an interactive shell * + * Returns bool(true) if the shell was opened. + * Returns bool(false) if the shell was already open. + * + * @see self::isShellOpen() * @see self::read() * @see self::write() * @return bool + * @throws InsufficientSetupException if not authenticated * @throws \UnexpectedValueException on receipt of unexpected packets * @throws \RuntimeException on other errors */ - private function initShell() + public function openShell() { - if ($this->in_request_pty_exec === true) { - return true; + if ($this->isShellOpen()) { + return false; + } + + if (!$this->isAuthenticated()) { + throw new InsufficientSetupException('Operation disallowed prior to login()'); } $this->window_size_server_to_client[self::CHANNEL_SHELL] = $this->window_size; @@ -2907,14 +2909,18 @@ class SSH2 $this->channel_status[self::CHANNEL_SHELL] = NET_SSH2_MSG_CHANNEL_DATA; + $this->channel_id_last_interactive = self::CHANNEL_SHELL; + $this->bitmap |= self::MASK_SHELL; return true; } /** - * Return the channel to be used with read() / write() - * + * Return the channel to be used with read(), write(), and reset(), if none were specified + * @deprecated for lack of transparency in intended channel target, to be potentially replaced + * with method which guarantees open-ness of all yielded channels and throws + * error for multiple open channels * @see self::read() * @see self::write() * @return int @@ -2922,15 +2928,26 @@ class SSH2 private function get_interactive_channel() { switch (true) { - case $this->in_subsystem: + case $this->is_channel_status_data(self::CHANNEL_SUBSYSTEM): return self::CHANNEL_SUBSYSTEM; - case $this->in_request_pty_exec: + case $this->is_channel_status_data(self::CHANNEL_EXEC): return self::CHANNEL_EXEC; default: return self::CHANNEL_SHELL; } } + /** + * Indicates the DATA status on the given channel + * + * @param int $channel The channel number to evaluate + * @return bool + */ + private function is_channel_status_data($channel) + { + return isset($this->channel_status[$channel]) && $this->channel_status[$channel] == NET_SSH2_MSG_CHANNEL_DATA; + } + /** * Return an available open channel * @@ -2987,27 +3004,38 @@ class SSH2 * Returns when there's a match for $expect, which can take the form of a string literal or, * if $mode == self::READ_REGEX, a regular expression. * + * If not specifying a channel, an open interactive channel will be selected, or, if there are + * no open channels, an interactive shell will be created. If there are multiple open + * interactive channels, a legacy behavior will apply in which channel selection prioritizes + * an active subsystem, the exec pty, and, lastly, the shell. If using multiple interactive + * channels, callers are discouraged from relying on this legacy behavior and should specify + * the intended channel. + * * @see self::write() * @param string $expect - * @param int $mode + * @param int $mode One of the self::READ_* constants + * @param int|null $channel Channel id returned by self::getInteractiveChannelId() * @return string|bool|null * @throws \RuntimeException on connection error + * @throws InsufficientSetupException on unexpected channel status, possibly due to closure */ - public function read($expect = '', $mode = self::READ_SIMPLE) + public function read($expect = '', $mode = self::READ_SIMPLE, $channel = null) { $this->curTimeout = $this->timeout; $this->is_timeout = false; - if (!$this->isAuthenticated()) { - throw new InsufficientSetupException('Operation disallowed prior to login()'); + if ($channel === null) { + $channel = $this->get_interactive_channel(); } - if (!($this->bitmap & self::MASK_SHELL) && !$this->initShell()) { - throw new \RuntimeException('Unable to initiate an interactive shell session'); + if (!$this->isInteractiveChannelOpen($channel)) { + if ($channel != self::CHANNEL_SHELL) { + throw new InsufficientSetupException('Data is not available on channel'); + } elseif (!$this->openShell()) { + throw new \RuntimeException('Unable to initiate an interactive shell session'); + } } - $channel = $this->get_interactive_channel(); - if ($mode == self::READ_NEXT) { return $this->get_channel_packet($channel); } @@ -3024,7 +3052,6 @@ class SSH2 } $response = $this->get_channel_packet($channel); if ($response === true) { - $this->in_request_pty_exec = false; return Strings::shift($this->interactiveBuffer, strlen($this->interactiveBuffer)); } @@ -3035,22 +3062,35 @@ class SSH2 /** * Inputs a command into an interactive shell. * + * If not specifying a channel, an open interactive channel will be selected, or, if there are + * no open channels, an interactive shell will be created. If there are multiple open + * interactive channels, a legacy behavior will apply in which channel selection prioritizes + * an active subsystem, the exec pty, and, lastly, the shell. If using multiple interactive + * channels, callers are discouraged from relying on this legacy behavior and should specify + * the intended channel. + * * @see SSH2::read() * @param string $cmd + * @param int|null $channel Channel id returned by self::getInteractiveChannelId() * @return void * @throws \RuntimeException on connection error + * @throws InsufficientSetupException on unexpected channel status, possibly due to closure */ - public function write($cmd) + public function write($cmd, $channel = null) { - if (!$this->isAuthenticated()) { - throw new InsufficientSetupException('Operation disallowed prior to login()'); + if ($channel === null) { + $channel = $this->get_interactive_channel(); } - if (!($this->bitmap & self::MASK_SHELL) && !$this->initShell()) { - throw new \RuntimeException('Unable to initiate an interactive shell session'); + if (!$this->isInteractiveChannelOpen($channel)) { + if ($channel != self::CHANNEL_SHELL) { + throw new InsufficientSetupException('Data is not available on channel'); + } elseif (!$this->openShell()) { + throw new \RuntimeException('Unable to initiate an interactive shell session'); + } } - $this->send_channel_packet($this->get_interactive_channel(), $cmd); + $this->send_channel_packet($channel, $cmd); } /** @@ -3103,8 +3143,7 @@ class SSH2 $this->channel_status[self::CHANNEL_SUBSYSTEM] = NET_SSH2_MSG_CHANNEL_DATA; - $this->bitmap |= self::MASK_SHELL; - $this->in_subsystem = true; + $this->channel_id_last_interactive = self::CHANNEL_SUBSYSTEM; return true; } @@ -3117,8 +3156,9 @@ class SSH2 */ public function stopSubsystem() { - $this->in_subsystem = false; - $this->close_channel(self::CHANNEL_SUBSYSTEM); + if ($this->isInteractiveChannelOpen(self::CHANNEL_SUBSYSTEM)) { + $this->close_channel(self::CHANNEL_SUBSYSTEM); + } return true; } @@ -3127,10 +3167,23 @@ class SSH2 * * If read() timed out you might want to just close the channel and have it auto-restart on the next read() call * + * If not specifying a channel, an open interactive channel will be selected. If there are + * multiple open interactive channels, a legacy behavior will apply in which channel selection + * prioritizes an active subsystem, the exec pty, and, lastly, the shell. If using multiple + * interactive channels, callers are discouraged from relying on this legacy behavior and + * should specify the intended channel. + * + * @param int|null $channel Channel id returned by self::getInteractiveChannelId() + * @return void */ - public function reset() + public function reset($channel = null) { - $this->close_channel($this->get_interactive_channel()); + if ($channel === null) { + $channel = $this->get_interactive_channel(); + } + if ($this->isInteractiveChannelOpen($channel)) { + $this->close_channel($channel); + } } /** @@ -3189,6 +3242,49 @@ class SSH2 return (bool) ($this->bitmap & self::MASK_LOGIN); } + /** + * Is the interactive shell active? + * + * @return bool + */ + public function isShellOpen() + { + return $this->isInteractiveChannelOpen(self::CHANNEL_SHELL); + } + + /** + * Is the exec pty active? + * + * @return bool + */ + public function isPTYOpen() + { + return $this->isInteractiveChannelOpen(self::CHANNEL_EXEC); + } + + /** + * Is the given interactive channel active? + * + * @param int $channel Channel id returned by self::getInteractiveChannelId() + * @return bool + */ + public function isInteractiveChannelOpen($channel) + { + return $this->isAuthenticated() && $this->is_channel_status_data($channel); + } + + /** + * Returns a channel identifier, presently of the last interactive channel opened, regardless of current status. + * Returns 0 if no interactive channel has been opened. + * + * @see self::isInteractiveChannelOpen() + * @return int + */ + public function getInteractiveChannelId() + { + return $this->channel_id_last_interactive; + } + /** * Pings a server connection, or tries to reconnect if the connection has gone down * @@ -3773,9 +3869,8 @@ class SSH2 */ public function disablePTY() { - if ($this->in_request_pty_exec) { + if ($this->isPTYOpen()) { $this->close_channel(self::CHANNEL_EXEC); - $this->in_request_pty_exec = false; } $this->request_pty = false; } @@ -3801,6 +3896,7 @@ class SSH2 * - if the connection times out * - if the channel status is CHANNEL_OPEN and the response was CHANNEL_OPEN_CONFIRMATION * - if the channel status is CHANNEL_REQUEST and the response was CHANNEL_SUCCESS + * - if the channel status is CHANNEL_CLOSE and the response was CHANNEL_CLOSE * * bool(false) is returned if: * @@ -3968,7 +4064,10 @@ class SSH2 throw new \RuntimeException('Unable to fulfill channel request'); } case NET_SSH2_MSG_CHANNEL_CLOSE: - return $type == NET_SSH2_MSG_CHANNEL_CLOSE ? true : $this->get_channel_packet($client_channel, $skip_extended); + if ($client_channel == $channel && $type == NET_SSH2_MSG_CHANNEL_CLOSE) { + return true; + } + return $this->get_channel_packet($client_channel, $skip_extended); } } @@ -4003,9 +4102,8 @@ class SSH2 case NET_SSH2_MSG_CHANNEL_CLOSE: $this->curTimeout = 5; - if ($this->bitmap & self::MASK_SHELL) { - $this->bitmap &= ~self::MASK_SHELL; - } + $this->close_channel_bitmap($channel); + if ($this->channel_status[$channel] != NET_SSH2_MSG_CHANNEL_EOF) { $this->send_binary_packet(pack('CN', NET_SSH2_MSG_CHANNEL_CLOSE, $this->server_channels[$channel])); } @@ -4348,16 +4446,29 @@ class SSH2 while (!is_bool($this->get_channel_packet($client_channel))) { } - if ($this->is_timeout) { - $this->disconnect(); - } - if ($want_reply) { $this->send_binary_packet(pack('CN', NET_SSH2_MSG_CHANNEL_CLOSE, $this->server_channels[$client_channel])); } - if ($this->bitmap & self::MASK_SHELL) { - $this->bitmap &= ~self::MASK_SHELL; + $this->close_channel_bitmap($client_channel); + } + + /** + * Maintains execution state bitmap in response to channel closure + * + * @param int $client_channel The channel number to maintain closure status of + * @return void + */ + private function close_channel_bitmap($client_channel) + { + switch ($client_channel) { + case self::CHANNEL_SHELL: + // Shell status has been maintained in the bitmap for backwards + // compatibility sake, but can be removed going forward + if ($this->bitmap & self::MASK_SHELL) { + $this->bitmap &= ~self::MASK_SHELL; + } + break; } } diff --git a/tests/Functional/Net/SSH2Test.php b/tests/Functional/Net/SSH2Test.php index 632ab1cb..83d35fc3 100644 --- a/tests/Functional/Net/SSH2Test.php +++ b/tests/Functional/Net/SSH2Test.php @@ -13,9 +13,34 @@ use phpseclib3\Tests\PhpseclibFunctionalTestCase; class SSH2Test extends PhpseclibFunctionalTestCase { + /** + * @return SSH2 + */ + public function getSSH2() + { + return new SSH2($this->getEnv('SSH_HOSTNAME'), 22); + } + + /** + * @return SSH2 + */ + public function getSSH2Login() + { + $ssh = $this->getSSH2(); + + $username = $this->getEnv('SSH_USERNAME'); + $password = $this->getEnv('SSH_PASSWORD'); + $this->assertTrue( + $ssh->login($username, $password), + 'SSH2 login using password failed.' + ); + + return $ssh; + } + public function testConstructor() { - $ssh = new SSH2($this->getEnv('SSH_HOSTNAME')); + $ssh = $this->getSSH2(); $this->assertIsObject( $ssh, @@ -42,6 +67,17 @@ class SSH2Test extends PhpseclibFunctionalTestCase 'Failed asserting that SSH2 is not authenticated after construction.' ); + $this->assertFalse( + $ssh->isShellOpen(), + 'Failed asserting that SSH2 does not have open shell after construction.' + ); + + $this->assertEquals( + 0, + $ssh->getInteractiveChannelId(), + 'Failed asserting that channel identifier 0 is returned.' + ); + $this->assertNotEmpty( $ssh->getServerPublicHostKey(), 'Failed asserting that a non-empty public host key was fetched.' @@ -82,6 +118,11 @@ class SSH2Test extends PhpseclibFunctionalTestCase 'Failed asserting that SSH2 is not authenticated after bad login attempt.' ); + $this->assertFalse( + $ssh->isShellOpen(), + 'Failed asserting that SSH2 does not have open shell after bad login attempt.' + ); + return $ssh; } @@ -102,6 +143,11 @@ class SSH2Test extends PhpseclibFunctionalTestCase 'Failed asserting that SSH2 is authenticated after good login attempt.' ); + $this->assertFalse( + $ssh->isShellOpen(), + 'Failed asserting that SSH2 does not have open shell after good login attempt.' + ); + return $ssh; } @@ -121,12 +167,28 @@ class SSH2Test extends PhpseclibFunctionalTestCase ->will($this->returnValue(true)); $ssh->exec('pwd', [$callbackObject, 'callbackMethod']); + $this->assertFalse( + $ssh->isPTYOpen(), + 'Failed asserting that SSH2 does not have open exec channel after exec.' + ); + + $this->assertFalse( + $ssh->isShellOpen(), + 'Failed asserting that SSH2 does not have open shell channel after exec.' + ); + + $this->assertEquals( + 0, + $ssh->getInteractiveChannelId(), + 'Failed asserting that channel identifier 0 is returned after exec.' + ); + return $ssh; } public function testGetServerPublicHostKey() { - $ssh = new SSH2($this->getEnv('SSH_HOSTNAME')); + $ssh = $this->getSSH2(); $this->assertIsString($ssh->getServerPublicHostKey()); } @@ -151,11 +213,65 @@ class SSH2Test extends PhpseclibFunctionalTestCase public function testDisablePTY(SSH2 $ssh) { $ssh->enablePTY(); + + $this->assertTrue( + $ssh->isPTYEnabled(), + 'Failed asserting that pty was enabled.' + ); + + $this->assertFalse( + $ssh->isPTYOpen(), + 'Failed asserting that pty was not open after enable.' + ); + + $this->assertEquals( + 0, + $ssh->getInteractiveChannelId(), + 'Failed asserting that 0 channel identifier is returned prior to opening.' + ); + $ssh->exec('ls -latr'); + + $this->assertTrue( + $ssh->isPTYOpen(), + 'Failed asserting that pty was open.' + ); + + $this->assertFalse( + $ssh->isShellOpen(), + 'Failed asserting that shell was not open after pty exec.' + ); + + $this->assertEquals( + SSH2::CHANNEL_EXEC, + $ssh->getInteractiveChannelId(), + 'Failed asserting that exec channel identifier is returned after exec.' + ); + $ssh->disablePTY(); + + $this->assertFalse( + $ssh->isPTYEnabled(), + 'Failed asserting that pty was disabled.' + ); + + $this->assertFalse( + $ssh->isPTYOpen(), + 'Failed asserting that pty was not open after disable.' + ); + + $this->assertEquals( + SSH2::CHANNEL_EXEC, + $ssh->getInteractiveChannelId(), + 'Failed asserting that exec channel identifier is returned after pty exec close.' + ); + $ssh->exec('pwd'); - $this->assertTrue(true); + $this->assertFalse( + $ssh->isPTYOpen(), + 'Failed asserting that pty was not open after exec.' + ); return $ssh; } @@ -182,13 +298,248 @@ class SSH2Test extends PhpseclibFunctionalTestCase $ssh->write("ping 127.0.0.1\n"); + $this->assertTrue( + $ssh->isShellOpen(), + 'Failed asserting that SSH2 has open shell after shell read/write.' + ); + + $this->assertFalse( + $ssh->isPTYOpen(), + 'Failed asserting that pty was not open after shell read/write.' + ); + + $this->assertEquals( + SSH2::CHANNEL_SHELL, + $ssh->getInteractiveChannelId(), + 'Failed asserting that shell channel identifier is returned after shell read/write.' + ); + $ssh->enablePTY(); - $ssh->exec('bash'); + + $this->assertTrue( + $ssh->exec('bash'), + 'Failed asserting exec command succeeded.' + ); + + $this->assertTrue( + $ssh->isShellOpen(), + 'Failed asserting that SSH2 has open shell after pty exec.' + ); + + $this->assertTrue( + $ssh->isPTYOpen(), + 'Failed asserting that pty was not open after exec.' + ); + + $this->assertEquals( + SSH2::CHANNEL_EXEC, + $ssh->getInteractiveChannelId(), + 'Failed asserting that exec channel identifier is returned after pty exec.' + ); $ssh->write("ls -latr\n"); $ssh->setTimeout(1); $this->assertIsString($ssh->read()); + + $this->assertTrue( + $ssh->isTimeout(), + 'Failed asserting that pty exec read timed out' + ); + + $this->assertTrue( + $ssh->isShellOpen(), + 'Failed asserting that SSH2 shell remains open across pty exec read/write.' + ); + + $this->assertTrue( + $ssh->isPTYOpen(), + 'Failed asserting that pty was open after read timeout.' + ); + } + + public function testOpenShell() + { + $ssh = $this->getSSH2Login(); + + $this->assertTrue( + $ssh->openShell(), + 'SSH2 shell initialization failed.' + ); + + $this->assertTrue( + $ssh->isShellOpen(), + 'Failed asserting that SSH2 has open shell after init.' + ); + + $this->assertNotFalse( + $ssh->read(), + 'Failed asserting that read succeeds.' + ); + + $ssh->write('hello'); + + $this->assertTrue( + $ssh->isShellOpen(), + 'Failed asserting that SSH2 has open shell after read/write.' + ); + + $this->assertEquals( + SSH2::CHANNEL_SHELL, + $ssh->getInteractiveChannelId(), + 'Failed asserting that shell channel identifier is returned after read/write.' + ); + + return $ssh; + } + + /** + * @depends testOpenShell + */ + public function testResetOpenShell(SSH2 $ssh) + { + $ssh->reset(); + + $this->assertFalse( + $ssh->isShellOpen(), + 'Failed asserting that SSH2 has open shell after reset.' + ); + + $this->assertEquals( + SSH2::CHANNEL_SHELL, + $ssh->getInteractiveChannelId(), + 'Failed asserting that shell channel identifier is returned after reset.' + ); + } + + public function testMultipleExecPty() + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('If you want to run multiple exec()\'s you will need to disable (and re-enable if appropriate) a PTY for each one.'); + + $ssh = $this->getSSH2Login(); + + $ssh->enablePTY(); + + $ssh->exec('bash'); + + $ssh->exec('bash'); + } + + public function testMultipleInteractiveChannels() + { + $ssh = $this->getSSH2Login(); + + $this->assertTrue( + $ssh->openShell(), + 'SSH2 shell initialization failed.' + ); + + $this->assertEquals( + SSH2::CHANNEL_SHELL, + $ssh->getInteractiveChannelId(), + 'Failed asserting that shell channel identifier is returned after open shell.' + ); + + $ssh->setTimeout(1); + + $this->assertIsString( + $ssh->read(), + 'Failed asserting that read succeeds after shell init' + ); + + $directory = $ssh->exec('pwd'); + + $this->assertFalse( + $ssh->isTimeout(), + 'failed' + ); + + $this->assertIsString( + $directory, + 'Failed asserting that exec succeeds after shell read/write' + ); + + $ssh->write("pwd\n"); + + $this->assertStringContainsString( + trim($directory), + $ssh->read(), + 'Failed asserting that current directory can be read from shell after exec' + ); + + $ssh->enablePTY(); + + $this->assertTrue( + $ssh->exec('bash'), + 'Failed asserting that pty exec succeeds' + ); + + $this->assertTrue( + $ssh->isShellOpen(), + 'Failed asserting that SSH2 has open shell after pty exec.' + ); + + $this->assertEquals( + SSH2::CHANNEL_EXEC, + $ssh->getInteractiveChannelId(), + 'Failed asserting that exec channel identifier is returned after pty exec.' + ); + + $ssh->write("pwd\n", SSH2::CHANNEL_SHELL); + + $this->assertStringContainsString( + trim($directory), + $ssh->read('', SSH2::READ_SIMPLE, SSH2::CHANNEL_SHELL), + 'Failed asserting that current directory can be read from shell after pty exec' + ); + + $this->assertTrue( + $ssh->isPTYOpen(), + 'Failed asserting that SSH2 has open pty exec after shell read/write.' + ); + + $ssh->write("pwd\n", SSH2::CHANNEL_EXEC); + + $this->assertIsString( + $ssh->read('', SSH2::READ_SIMPLE, SSH2::CHANNEL_EXEC), + 'Failed asserting that pty exec read succeeds' + ); + + $ssh->reset(SSH2::CHANNEL_EXEC); + + $this->assertFalse( + $ssh->isPTYOpen(), + 'Failed asserting that SSH2 has closed pty exec after reset.' + ); + + $ssh->disablePTY(); + + $this->assertTrue( + $ssh->isShellOpen(), + 'Failed asserting that SSH2 has open shell after pty exec.' + ); + + $ssh->write("pwd\n", SSH2::CHANNEL_SHELL); + + $this->assertStringContainsString( + trim($directory), + $ssh->read('', SSH2::READ_SIMPLE, SSH2::CHANNEL_SHELL), + 'Failed asserting that current directory can be read from shell after pty exec' + ); + + $ssh->reset(SSH2::CHANNEL_SHELL); + + $this->assertFalse( + $ssh->isShellOpen(), + 'Failed asserting that SSH2 has closed shell after reset.' + ); + + $this->assertEquals( + SSH2::CHANNEL_EXEC, + $ssh->getInteractiveChannelId(), + 'Failed asserting that exec channel identifier is maintained as last opened channel.' + ); } } diff --git a/tests/Unit/Net/SSH2UnitTest.php b/tests/Unit/Net/SSH2UnitTest.php index d41358a8..c6b8fa15 100644 --- a/tests/Unit/Net/SSH2UnitTest.php +++ b/tests/Unit/Net/SSH2UnitTest.php @@ -8,6 +8,7 @@ namespace phpseclib3\Tests\Unit\Net; +use phpseclib3\Exception\InsufficientSetupException; use phpseclib3\Net\SSH2; use phpseclib3\Tests\PhpseclibTestCase; @@ -146,6 +147,71 @@ class SSH2UnitTest extends PhpseclibTestCase $this->assertSame('{' . spl_object_hash($ssh) . '}', $ssh->getResourceId()); } + /** + * @requires PHPUnit < 10 + */ + public function testReadUnauthenticated() + { + $this->expectException(InsufficientSetupException::class); + $this->expectExceptionMessage('Operation disallowed prior to login()'); + + $ssh = $this->createSSHMock(); + + $ssh->read(); + } + + /** + * @requires PHPUnit < 10 + */ + public function testWriteUnauthenticated() + { + $this->expectException(InsufficientSetupException::class); + $this->expectExceptionMessage('Operation disallowed prior to login()'); + + $ssh = $this->createSSHMock(); + + $ssh->write(''); + } + + /** + * @requires PHPUnit < 10 + */ + public function testWriteOpensShell() + { + $ssh = $this->getMockBuilder(SSH2::class) + ->disableOriginalConstructor() + ->setMethods(['__destruct', 'isAuthenticated', 'openShell', 'send_channel_packet']) + ->getMock(); + $ssh->expects($this->once()) + ->method('isAuthenticated') + ->willReturn(true); + $ssh->expects($this->once()) + ->method('openShell') + ->willReturn(true); + $ssh->expects($this->once()) + ->method('send_channel_packet') + ->with(SSH2::CHANNEL_SHELL, 'hello'); + + $ssh->write('hello'); + } + + /** + * @requires PHPUnit < 10 + */ + public function testOpenShellWhenOpen() + { + $ssh = $this->getMockBuilder(SSH2::class) + ->disableOriginalConstructor() + ->setMethods(['__destruct', 'isShellOpen']) + ->getMock(); + + $ssh->expects($this->once()) + ->method('isShellOpen') + ->willReturn(true); + + $this->assertFalse($ssh->openShell()); + } + public function testGetTimeout() { $ssh = new SSH2('localhost');