Merge pull request #1897 from rposky/3.0

SSH2: Better support for multiple interactive channels & expose shell functions: 3.0 Backport
This commit is contained in:
terrafrost 2023-03-23 12:16:56 -05:00 committed by GitHub
commit 0f8bc61538
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 590 additions and 62 deletions

View File

@ -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,26 +3004,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.
*
* @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()) {
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()) {
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;
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,17 +4446,30 @@ 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]));
}
$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;
}
}
/**

View File

@ -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.'
);
}
}

View File

@ -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');