diff --git a/phpseclib/Net/SSH2.php b/phpseclib/Net/SSH2.php index 2b58f5a2..30ede297 100644 --- a/phpseclib/Net/SSH2.php +++ b/phpseclib/Net/SSH2.php @@ -549,6 +549,13 @@ class SSH2 */ protected array $channel_status = []; + /** + * The identifier of the interactive channel which was opened most recently + * + * @see self::getInteractiveChannelId() + */ + private int $channel_id_last_interactive = 0; + /** * Packet Size * @@ -721,16 +728,6 @@ class SSH2 */ private bool $request_pty = false; - /** - * Flag set while exec() is running when using enablePTY() - */ - private bool $in_request_pty_exec = false; - - /** - * Flag set after startSubsystem() is called - */ - private bool $in_subsystem = false; - /** * Contents of stdError */ @@ -2431,7 +2428,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.'); } @@ -2481,8 +2478,6 @@ class SSH2 $this->disconnect_helper(DisconnectReason::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 @@ -2512,7 +2507,8 @@ class SSH2 $this->channel_status[self::CHANNEL_EXEC] = MessageType::CHANNEL_DATA; - if ($this->in_request_pty_exec) { + if ($this->request_pty === true) { + $this->channel_id_last_interactive = self::CHANNEL_EXEC; return true; } @@ -2540,15 +2536,24 @@ class SSH2 /** * Creates an interactive shell * + * Returns bool(true) if the shell was opened. + * Returns bool(false) if the shell was already open. + * + * @throws InsufficientSetupException if not authenticated * @throws UnexpectedValueException on receipt of unexpected packets * @throws RuntimeException on other errors - *@see self::read() + * @see self::isShellOpen() + * @see self::read() * @see self::write() */ - private function initShell(): bool + public function openShell(): bool { - 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; @@ -2608,29 +2613,41 @@ class SSH2 $this->channel_status[self::CHANNEL_SHELL] = MessageType::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() */ private function get_interactive_channel(): int { 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 + */ + private function is_channel_status_data(int $channel): bool + { + return isset($this->channel_status[$channel]) && $this->channel_status[$channel] == MessageType::CHANNEL_DATA; + } + /** * Return an available open channel * @@ -2685,25 +2702,37 @@ 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. + * + * @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 * @see self::write() */ - public function read(string $expect = '', int $mode = self::READ_SIMPLE) + public function read(string $expect = '', int $mode = self::READ_SIMPLE, int $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); } @@ -2720,7 +2749,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)); } @@ -2731,20 +2759,33 @@ 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. + * + * @param int|null $channel Channel id returned by self::getInteractiveChannelId() * @throws RuntimeException on connection error + * @throws InsufficientSetupException on unexpected channel status, possibly due to closure * @see SSH2::read() */ - public function write(string $cmd): void + public function write(string $cmd, int $channel = null): void { - 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); } /** @@ -2795,8 +2836,7 @@ class SSH2 $this->channel_status[self::CHANNEL_SUBSYSTEM] = MessageType::CHANNEL_DATA; - $this->bitmap |= self::MASK_SHELL; - $this->in_subsystem = true; + $this->channel_id_last_interactive = self::CHANNEL_SUBSYSTEM; return true; } @@ -2808,8 +2848,9 @@ class SSH2 */ public function stopSubsystem(): bool { - $this->in_subsystem = false; - $this->close_channel(self::CHANNEL_SUBSYSTEM); + if ($this->isInteractiveChannelOpen(self::CHANNEL_SUBSYSTEM)) { + $this->close_channel(self::CHANNEL_SUBSYSTEM); + } return true; } @@ -2817,10 +2858,23 @@ class SSH2 * Closes a channel * * 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() */ - public function reset(): void + public function reset(int $channel = null): void { - $this->close_channel($this->get_interactive_channel()); + if ($channel === null) { + $channel = $this->get_interactive_channel(); + } + if ($this->isInteractiveChannelOpen($channel)) { + $this->close_channel($channel); + } } /** @@ -2872,6 +2926,43 @@ class SSH2 return (bool) ($this->bitmap & self::MASK_LOGIN); } + /** + * Is the interactive shell active? + */ + public function isShellOpen(): bool + { + return $this->isInteractiveChannelOpen(self::CHANNEL_SHELL); + } + + /** + * Is the exec pty active? + */ + public function isPTYOpen(): bool + { + return $this->isInteractiveChannelOpen(self::CHANNEL_EXEC); + } + + /** + * Is the given interactive channel active? + * + * @param int $channel Channel id returned by self::getInteractiveChannelId() + */ + public function isInteractiveChannelOpen(int $channel): bool + { + 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() + */ + public function getInteractiveChannelId(): int + { + return $this->channel_id_last_interactive; + } + /** * Pings a server connection, or tries to reconnect if the connection has gone down * @@ -3448,9 +3539,8 @@ class SSH2 */ public function disablePTY(): void { - 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; } @@ -3475,6 +3565,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: * @@ -3639,7 +3730,10 @@ class SSH2 throw new RuntimeException('Unable to fulfill channel request'); } case MessageType::CHANNEL_CLOSE: - return $type == MessageType::CHANNEL_CLOSE ? true : $this->get_channel_packet($client_channel, $skip_extended); + if ($client_channel == $channel && $type == MessageType::CHANNEL_CLOSE) { + return true; + } + return $this->get_channel_packet($client_channel, $skip_extended); } } @@ -3674,9 +3768,8 @@ class SSH2 case MessageType::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] != MessageType::CHANNEL_EOF) { $this->send_binary_packet(pack('CN', MessageType::CHANNEL_CLOSE, $this->server_channels[$channel])); } @@ -4002,16 +4095,26 @@ 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', MessageType::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 + */ + private function close_channel_bitmap(int $client_channel): void + { + 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 614af984..a60f3d5c 100644 --- a/tests/Functional/Net/SSH2Test.php +++ b/tests/Functional/Net/SSH2Test.php @@ -15,9 +15,28 @@ use phpseclib3\Tests\PhpseclibFunctionalTestCase; class SSH2Test extends PhpseclibFunctionalTestCase { + public function getSSH2(): SSH2 + { + return new SSH2($this->getEnv('SSH_HOSTNAME'), 22); + } + + public function getSSH2Login(): SSH2 + { + $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(): SSH2 { - $ssh = new SSH2($this->getEnv('SSH_HOSTNAME')); + $ssh = $this->getSSH2(); $this->assertIsObject( $ssh, @@ -44,6 +63,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.' @@ -84,6 +114,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; } @@ -104,6 +139,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; } @@ -123,12 +163,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(): void { - $ssh = new SSH2($this->getEnv('SSH_HOSTNAME')); + $ssh = $this->getSSH2(); $this->assertIsString($ssh->getServerPublicHostKey()); } @@ -153,11 +209,65 @@ class SSH2Test extends PhpseclibFunctionalTestCase public function testDisablePTY(SSH2 $ssh): SSH2 { $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; } @@ -184,13 +294,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(): SSH2 + { + $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): void + { + $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(): void + { + $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(): void + { + $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 3ab39a65..8bb4e809 100644 --- a/tests/Unit/Net/SSH2UnitTest.php +++ b/tests/Unit/Net/SSH2UnitTest.php @@ -10,6 +10,7 @@ declare(strict_types=1); namespace phpseclib3\Tests\Unit\Net; +use phpseclib3\Exception\InsufficientSetupException; use phpseclib3\Net\SSH2; use phpseclib3\Tests\PhpseclibTestCase; @@ -143,6 +144,71 @@ class SSH2UnitTest extends PhpseclibTestCase $this->assertSame('{' . spl_object_hash($ssh) . '}', $ssh->getResourceId()); } + /** + * @requires PHPUnit < 10 + */ + public function testReadUnauthenticated(): void + { + $this->expectException(InsufficientSetupException::class); + $this->expectExceptionMessage('Operation disallowed prior to login()'); + + $ssh = $this->createSSHMock(); + + $ssh->read(); + } + + /** + * @requires PHPUnit < 10 + */ + public function testWriteUnauthenticated(): void + { + $this->expectException(InsufficientSetupException::class); + $this->expectExceptionMessage('Operation disallowed prior to login()'); + + $ssh = $this->createSSHMock(); + + $ssh->write(''); + } + + /** + * @requires PHPUnit < 10 + */ + public function testWriteOpensShell(): void + { + $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(): void + { + $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(): void { $ssh = new SSH2('localhost');