Get rid of SftpSession

Should be substituted by FilePath actions using remote paths.

Change-Id: Ib1e3913cc94d417045cbe6b922284a2f8ab6d71f
Reviewed-by: <github-actions-qt-creator@cristianadam.eu>
Reviewed-by: Qt CI Bot <qt_ci_bot@qt-project.org>
Reviewed-by: hjk <hjk@qt.io>
This commit is contained in:
Jarek Kobus
2022-04-07 16:01:23 +02:00
parent 5715d433f4
commit a6fc7727a1
8 changed files with 0 additions and 616 deletions

View File

@@ -2,7 +2,6 @@ add_qtc_library(QtcSsh
DEPENDS Qt5::Core Qt5::Network Qt5::Widgets Utils
SOURCES
sftpdefs.cpp sftpdefs.h
sftpsession.cpp sftpsession.h
sftptransfer.cpp sftptransfer.h
ssh.qrc
ssh_global.h

View File

@@ -37,8 +37,6 @@ namespace QSsh {
class SftpTransfer;
using SftpTransferPtr = std::unique_ptr<SftpTransfer>;
class SftpSession;
using SftpSessionPtr = std::unique_ptr<SftpSession>;
enum class FileTransferErrorHandling { Abort, Ignore };

View File

@@ -1,337 +0,0 @@
/****************************************************************************
**
** Copyright (C) 2018 The Qt Company Ltd.
** Contact: https://www.qt.io/licensing/
**
** This file is part of Qt Creator.
**
** Commercial License Usage
** Licensees holding valid commercial Qt licenses may use this file in
** accordance with the commercial license agreement provided with the
** Software or, alternatively, in accordance with the terms contained in
** a written agreement between you and The Qt Company. For licensing terms
** and conditions see https://www.qt.io/terms-conditions. For further
** information use the contact form at https://www.qt.io/contact-us.
**
** GNU General Public License Usage
** Alternatively, this file may be used under the terms of the GNU
** General Public License version 3 as published by the Free Software
** Foundation with exceptions as appearing in the file LICENSE.GPL3-EXCEPT
** included in the packaging of this file. Please review the following
** information to ensure the GNU General Public License requirements will
** be met: https://www.gnu.org/licenses/gpl-3.0.html.
**
****************************************************************************/
#include "sftpsession.h"
#include "sshlogging_p.h"
#include "sshremoteprocess.h"
#include "sshsettings.h"
#include <utils/fileutils.h>
#include <utils/commandline.h>
#include <utils/qtcassert.h>
#include <QByteArrayList>
#include <QFileInfo>
#include <QQueue>
#include <QTimer>
using namespace Utils;
namespace QSsh {
using namespace Internal;
enum class CommandType { Ls, Mkdir, Rmdir, Rm, Rename, Ln, Put, Get, None };
struct Command
{
Command() = default;
Command(CommandType t, const QStringList &p, SftpJobId id) : type(t), paths(p), jobId(id) {}
bool isValid() const { return type != CommandType::None; }
CommandType type = CommandType::None;
QStringList paths;
SftpJobId jobId = SftpInvalidJob;
};
struct SftpSession::SftpSessionPrivate
{
QtcProcess sftpProc;
QStringList connectionArgs;
QByteArray output;
QQueue<Command> pendingCommands;
Command activeCommand;
SftpJobId nextJobId = 1;
SftpSession::State state = SftpSession::State::Inactive;
QByteArray commandString(CommandType command) const
{
switch (command) {
case CommandType::Ls: return "ls -n";
case CommandType::Mkdir: return "mkdir";
case CommandType::Rmdir: return "rmdir";
case CommandType::Rm: return "rm";
case CommandType::Rename: return "rename";
case CommandType::Ln: return "ln -s";
case CommandType::Put: return "put";
case CommandType::Get: return "get";
default: QTC_ASSERT(false, return QByteArray());
}
}
SftpJobId queueCommand(CommandType command, const QStringList &paths)
{
qCDebug(sshLog) << "queueing command" << int(command) << paths;
const SftpJobId jobId = nextJobId++;
pendingCommands.enqueue(Command(command, paths, jobId));
runNextCommand();
return jobId;
}
void runNextCommand()
{
if (activeCommand.isValid())
return;
if (pendingCommands.empty())
return;
QTC_ASSERT(sftpProc.isRunning(), return);
activeCommand = pendingCommands.dequeue();
// The second newline forces the prompt to appear after the command has finished.
sftpProc.write(commandString(activeCommand.type) + ' '
+ ProcessArgs::createUnixArgs(activeCommand.paths)
.toString().toLocal8Bit() + "\n\n");
}
};
static QByteArray prompt() { return "sftp> "; }
SftpSession::SftpSession(const QStringList &connectionArgs) : d(new SftpSessionPrivate)
{
SshRemoteProcess::setupSshEnvironment(&d->sftpProc);
d->sftpProc.setProcessMode(ProcessMode::Writer);
d->connectionArgs = connectionArgs;
connect(&d->sftpProc, &QtcProcess::started, [this] {
qCDebug(sshLog) << "sftp process started";
d->sftpProc.write("\n"); // Force initial prompt.
});
connect(&d->sftpProc, &QtcProcess::done, [this] {
qCDebug(sshLog) << "sftp process finished";
d->state = State::Inactive;
const QString processMessage = [this] {
if (d->sftpProc.error() == QProcess::FailedToStart)
return tr("sftp failed to start: %1").arg(d->sftpProc.errorString());
if (d->sftpProc.exitStatus() != QProcess::NormalExit)
return tr("sftp crashed.");
if (d->sftpProc.exitCode() != 0)
return QString::fromLocal8Bit(d->sftpProc.readAllStandardError());
return QString();
}();
emit done(processMessage);
});
connect(&d->sftpProc, &QtcProcess::readyReadStandardOutput, this, &SftpSession::handleStdout);
}
void SftpSession::doStart()
{
if (d->state != State::Starting)
return;
const FilePath sftpBinary = SshSettings::sftpFilePath();
if (!sftpBinary.exists()) {
d->state = State::Inactive;
emit done(tr("Cannot establish SFTP session: sftp binary \"%1\" does not exist.")
.arg(sftpBinary.toUserOutput()));
return;
}
d->activeCommand = Command();
const QStringList args = QStringList{"-q"} << d->connectionArgs;
qCDebug(sshLog) << "starting sftp session:" << sftpBinary.toUserOutput() << args;
d->sftpProc.setCommand(CommandLine(sftpBinary, args));
d->sftpProc.start();
}
void SftpSession::handleStdout()
{
if (state() == State::Running && !d->activeCommand.isValid()) {
qCWarning(sshLog) << "ignoring unexpected sftp output:"
<< d->sftpProc.readAllStandardOutput();
return;
}
d->output += d->sftpProc.readAllStandardOutput();
qCDebug(sshLog) << "accumulated sftp output:" << d->output;
const int firstPromptOffset = d->output.indexOf(prompt());
if (firstPromptOffset == -1)
return;
if (state() == State::Starting) {
d->state = State::Running;
d->output.clear();
d->sftpProc.readAllStandardError(); // The "connected" message goes to stderr.
emit started();
return;
}
const int secondPromptOffset = d->output.indexOf(prompt(), firstPromptOffset + prompt().size());
if (secondPromptOffset == -1)
return;
const Command command = d->activeCommand;
d->activeCommand = Command();
const QByteArray commandOutput = d->output.mid(
firstPromptOffset + prompt().size(),
secondPromptOffset - firstPromptOffset - prompt().size());
d->output = d->output.mid(secondPromptOffset + prompt().size());
if (command.type == CommandType::Ls)
handleLsOutput(command.jobId, commandOutput);
const QByteArray stdErr = d->sftpProc.readAllStandardError();
emit commandFinished(command.jobId, QString::fromLocal8Bit(stdErr));
d->runNextCommand();
}
static SftpFileType typeFromLsOutput(char c)
{
if (c == '-')
return FileTypeRegular;
if (c == 'd')
return FileTypeDirectory;
return FileTypeOther;
}
static QFile::Permissions permissionsFromLsOutput(const QByteArray &output)
{
QFile::Permissions perms;
if (output.at(0) == 'r')
perms |= QFile::ReadOwner;
if (output.at(1) == 'w')
perms |= QFile::WriteOwner;
if (output.at(2) == 'x')
perms |= QFile::ExeOwner;
if (output.at(3) == 'r')
perms |= QFile::ReadGroup;
if (output.at(4) == 'w')
perms |= QFile::WriteGroup;
if (output.at(5) == 'x')
perms |= QFile::ExeGroup;
if (output.at(6) == 'r')
perms |= QFile::ReadOther;
if (output.at(7) == 'w')
perms |= QFile::WriteOther;
if (output.at(8) == 'x')
perms |= QFile::ExeOther;
return perms;
}
void SftpSession::handleLsOutput(SftpJobId jobId, const QByteArray &output)
{
QList<SftpFileInfo> allFileInfo;
for (const QByteArray &line : output.split('\n')) {
if (line.startsWith("ls") || line.isEmpty())
continue;
const QByteArrayList components = line.simplified().split(' ');
if (components.size() < 9) {
qCWarning(sshLog) << "Don't know how to parse sftp ls output:" << line;
continue;
}
const QByteArray typeAndPermissions = components.first();
if (typeAndPermissions.size() != 10) {
qCWarning(sshLog) << "Don't know how to parse sftp ls output:" << line;
continue;
}
SftpFileInfo fileInfo;
fileInfo.type = typeFromLsOutput(typeAndPermissions.at(0));
fileInfo.permissions = permissionsFromLsOutput(QByteArray::fromRawData(
typeAndPermissions.constData() + 1,
typeAndPermissions.size() - 1));
bool isNumber;
fileInfo.size = components.at(4).toULongLong(&isNumber);
if (!isNumber) {
qCWarning(sshLog) << "Don't know how to parse sftp ls output:" << line;
continue;
}
// TODO: This will not work for file names with weird whitespace combinations
fileInfo.name = QFileInfo(QString::fromUtf8(components.mid(8).join(' '))).fileName();
allFileInfo << fileInfo;
}
emit fileInfoAvailable(jobId, allFileInfo);
}
SftpSession::~SftpSession()
{
quit();
delete d;
}
void SftpSession::start()
{
QTC_ASSERT(d->state == State::Inactive, return);
d->state = State::Starting;
QTimer::singleShot(0, this, &SftpSession::doStart);
}
void SftpSession::quit()
{
qCDebug(sshLog) << "quitting sftp session, current state is" << int(state());
switch (state()) {
case State::Starting:
case State::Closing:
d->state = State::Closing;
d->sftpProc.kill();
break;
case State::Running:
d->state = State::Closing;
d->sftpProc.write("bye\n");
break;
case State::Inactive:
break;
}
}
SftpJobId SftpSession::ls(const QString &path)
{
return d->queueCommand(CommandType::Ls, QStringList(path));
}
SftpJobId SftpSession::createDirectory(const QString &path)
{
return d->queueCommand(CommandType::Mkdir, QStringList(path));
}
SftpJobId SftpSession::removeDirectory(const QString &path)
{
return d->queueCommand(CommandType::Rmdir, QStringList(path));
}
SftpJobId SftpSession::removeFile(const QString &path)
{
return d->queueCommand(CommandType::Rm, QStringList(path));
}
SftpJobId SftpSession::rename(const QString &oldPath, const QString &newPath)
{
return d->queueCommand(CommandType::Rename, QStringList{oldPath, newPath});
}
SftpJobId SftpSession::createSoftLink(const QString &filePath, const QString &target)
{
return d->queueCommand(CommandType::Ln, QStringList{filePath, target});
}
SftpJobId SftpSession::uploadFile(const QString &localFilePath, const QString &remoteFilePath)
{
return d->queueCommand(CommandType::Put, QStringList{localFilePath, remoteFilePath});
}
SftpJobId SftpSession::downloadFile(const QString &remoteFilePath, const QString &localFilePath)
{
return d->queueCommand(CommandType::Get, QStringList{remoteFilePath, localFilePath});
}
SftpSession::State SftpSession::state() const
{
return d->state;
}
} // namespace QSsh

View File

@@ -1,73 +0,0 @@
/****************************************************************************
**
** Copyright (C) 2018 The Qt Company Ltd.
** Contact: https://www.qt.io/licensing/
**
** This file is part of Qt Creator.
**
** Commercial License Usage
** Licensees holding valid commercial Qt licenses may use this file in
** accordance with the commercial license agreement provided with the
** Software or, alternatively, in accordance with the terms contained in
** a written agreement between you and The Qt Company. For licensing terms
** and conditions see https://www.qt.io/terms-conditions. For further
** information use the contact form at https://www.qt.io/contact-us.
**
** GNU General Public License Usage
** Alternatively, this file may be used under the terms of the GNU
** General Public License version 3 as published by the Free Software
** Foundation with exceptions as appearing in the file LICENSE.GPL3-EXCEPT
** included in the packaging of this file. Please review the following
** information to ensure the GNU General Public License requirements will
** be met: https://www.gnu.org/licenses/gpl-3.0.html.
**
****************************************************************************/
#pragma once
#include "sftpdefs.h"
#include "ssh_global.h"
#include <QObject>
namespace QSsh {
class SshConnection;
class QSSH_EXPORT SftpSession : public QObject
{
friend class SshConnection;
Q_OBJECT
public:
~SftpSession() override;
void start();
void quit();
SftpJobId ls(const QString &path);
SftpJobId createDirectory(const QString &path);
SftpJobId removeDirectory(const QString &path);
SftpJobId removeFile(const QString &path);
SftpJobId rename(const QString &oldPath, const QString &newPath);
SftpJobId createSoftLink(const QString &filePath, const QString &target);
SftpJobId uploadFile(const QString &localFilePath, const QString &remoteFilePath);
SftpJobId downloadFile(const QString &remoteFilePath, const QString &localFilePath);
enum class State { Inactive, Starting, Running, Closing };
State state() const;
signals:
void started();
void done(const QString &error);
void commandFinished(SftpJobId job, const QString &error);
void fileInfoAvailable(SftpJobId job, const QList<SftpFileInfo> &fileInfoList);
private:
SftpSession(const QStringList &connectionArgs);
void doStart();
void handleStdout();
void handleLsOutput(SftpJobId jobId, const QByteArray &output);
struct SftpSessionPrivate;
SftpSessionPrivate * const d;
};
} // namespace QSsh

View File

@@ -19,8 +19,6 @@ Project {
files: [
"sftpdefs.cpp",
"sftpdefs.h",
"sftpsession.cpp",
"sftpsession.h",
"sftptransfer.cpp",
"sftptransfer.h",
"ssh.qrc",

View File

@@ -25,7 +25,6 @@
#include "sshconnection.h"
#include "sftpsession.h"
#include "sftptransfer.h"
#include "sshlogging_p.h"
#include "sshremoteprocess.h"
@@ -324,12 +323,6 @@ SftpTransferPtr SshConnection::createDownload(const FilesToTransfer &files,
return setupTransfer(files, Internal::FileTransferType::Download, errorHandlingMode);
}
SftpSessionPtr SshConnection::createSftpSession()
{
QTC_ASSERT(state() == Connected, return SftpSessionPtr());
return SftpSessionPtr(new SftpSession(d->connectionArgs(SshSettings::sftpFilePath())));
}
void SshConnection::doConnectToHost()
{
if (d->state != Connecting)

View File

@@ -120,7 +120,6 @@ public:
FileTransferErrorHandling errorHandlingMode);
SftpTransferPtr createDownload(const FilesToTransfer &files,
FileTransferErrorHandling errorHandlingMode);
SftpSessionPtr createSftpSession();
signals:
void connected();

View File

@@ -23,7 +23,6 @@
**
****************************************************************************/
#include <ssh/sftpsession.h>
#include <ssh/sftptransfer.h>
#include <ssh/sshconnection.h>
#include <ssh/sshremoteprocessrunner.h>
@@ -150,7 +149,6 @@ void tst_Ssh::pristineConnectionObject()
QTest::ignoreMessage(QtDebugMsg, assertToIgnore);
QVERIFY(!connection.createRemoteProcess(""));
QTest::ignoreMessage(QtDebugMsg, assertToIgnore);
QVERIFY(!connection.createSftpSession());
}
void tst_Ssh::remoteProcess_data()
@@ -332,10 +330,6 @@ void tst_Ssh::sftp()
static const auto getRemoteFilePath = [](const QString &localFileName) {
return QString("/tmp/").append(localFileName).append(".upload");
};
const auto getDownloadFilePath = [](const QTemporaryDir &dirForFilesToDownload,
const QString &localFileName) {
return QString(dirForFilesToDownload.path()).append('/').append(localFileName);
};
FilesToTransfer filesToUpload;
std::srand(QDateTime::currentDateTime().toSecsSinceEpoch());
for (int i = 0; i < 100; ++i) {
@@ -384,193 +378,6 @@ void tst_Ssh::sftp()
QVERIFY(timer.isActive());
timer.stop();
QVERIFY2(jobError.isEmpty(), qPrintable(jobError));
// Establish interactive SFTP session
SftpSessionPtr sftpChannel = connection.createSftpSession();
QList<SftpJobId> jobs;
bool invalidFinishedSignal = false;
connect(sftpChannel.get(), &SftpSession::started, &loop, &QEventLoop::quit);
connect(sftpChannel.get(), &SftpSession::done, &loop, &QEventLoop::quit);
connect(sftpChannel.get(), &SftpSession::commandFinished,
[&loop, &jobs, &invalidFinishedSignal, &jobError](SftpJobId job, const QString &error) {
if (!jobs.removeOne(job)) {
invalidFinishedSignal = true;
loop.quit();
return;
}
if (!error.isEmpty()) {
jobError = error;
loop.quit();
return;
}
if (jobs.empty())
loop.quit();
});
timer.start();
sftpChannel->start();
loop.exec();
QVERIFY(timer.isActive());
timer.stop();
QVERIFY(!invalidFinishedSignal);
QCOMPARE(sftpChannel->state(), SftpSession::State::Running);
// Download the uploaded files to a different location
const QStringList allUploadedFileNames
= QDir(dirForFilesToUpload.path()).entryList(QDir::Files);
QCOMPARE(allUploadedFileNames.size(), 101);
for (const QString &fileName : allUploadedFileNames) {
const QString localFilePath = dirForFilesToUpload.path() + '/' + fileName;
const QString remoteFilePath = getRemoteFilePath(fileName);
const QString downloadFilePath = getDownloadFilePath(dirForFilesToDownload, fileName);
const SftpJobId downloadJob = sftpChannel->downloadFile(remoteFilePath, downloadFilePath);
QVERIFY(downloadJob != SftpInvalidJob);
jobs << downloadJob;
}
QCOMPARE(jobs.size(), 101);
loop.exec();
QVERIFY(!invalidFinishedSignal);
QVERIFY2(jobError.isEmpty(), qPrintable(jobError));
QCOMPARE(sftpChannel->state(), SftpSession::State::Running);
QVERIFY(jobs.empty());
// Compare contents of uploaded and downloaded files
bool success;
const auto compareFiles = [&](const QTemporaryDir &downloadDir) {
success = false;
for (const QString &fileName : allUploadedFileNames) {
QFile originalFile(dirForFilesToUpload.path() + '/' + fileName);
QVERIFY2(originalFile.open(QIODevice::ReadOnly), qPrintable(originalFile.errorString()));
QFile downloadedFile(getDownloadFilePath(downloadDir, fileName));
QVERIFY2(downloadedFile.open(QIODevice::ReadOnly),
qPrintable(downloadedFile.errorString()));
QVERIFY(originalFile.fileName() != downloadedFile.fileName());
QCOMPARE(originalFile.size(), downloadedFile.size());
qint64 bytesLeft = originalFile.size();
while (bytesLeft > 0) {
const qint64 bytesToRead = qMin(bytesLeft, Q_INT64_C(1024 * 1024));
const QByteArray origBlock = originalFile.read(bytesToRead);
const QByteArray copyBlock = downloadedFile.read(bytesToRead);
QCOMPARE(origBlock.size(), bytesToRead);
QCOMPARE(origBlock, copyBlock);
bytesLeft -= bytesToRead;
}
}
success = true;
};
compareFiles(dirForFilesToDownload);
QVERIFY(success);
// The same again, with a non-interactive download.
const FilesToTransfer filesToDownload = transform(filesToUpload, [&](const FileToTransfer &fileToUpload) {
return FileToTransfer(fileToUpload.targetFile,
getDownloadFilePath(dir2ForFilesToDownload,
QFileInfo(fileToUpload.sourceFile).fileName()));
});
const SftpTransferPtr download = connection.createDownload(filesToDownload,
FileTransferErrorHandling::Abort);
connect(download.get(), &SftpTransfer::done, [&jobError, &loop](const QString &error) {
jobError = error;
loop.quit();
});
QObject::connect(&timer, &QTimer::timeout, &loop, &QEventLoop::quit);
timer.setSingleShot(true);
timer.setInterval(30 * 1000);
timer.start();
download->start();
loop.exec();
QVERIFY(timer.isActive());
timer.stop();
QVERIFY2(jobError.isEmpty(), qPrintable(jobError));
compareFiles(dir2ForFilesToDownload);
QVERIFY(success);
// Remove the uploaded files on the remote system
timer.setInterval((params.timeout + 5) * 1000);
for (const QString &fileName : allUploadedFileNames) {
const QString remoteFilePath = getRemoteFilePath(fileName);
const SftpJobId removeJob = sftpChannel->removeFile(remoteFilePath);
QVERIFY(removeJob != SftpInvalidJob);
jobs << removeJob;
}
loop.exec();
QVERIFY(!invalidFinishedSignal);
QVERIFY2(jobError.isEmpty(), qPrintable(jobError));
QCOMPARE(sftpChannel->state(), SftpSession::State::Running);
QVERIFY(jobs.empty());
// Create a directory on the remote system
const QString remoteDirPath = "/tmp/sftptest-" + QDateTime::currentDateTime().toString();
const SftpJobId mkdirJob = sftpChannel->createDirectory(remoteDirPath);
QVERIFY(mkdirJob != SftpInvalidJob);
jobs << mkdirJob;
loop.exec();
QVERIFY(!invalidFinishedSignal);
QVERIFY2(jobError.isEmpty(), qPrintable(jobError));
QCOMPARE(sftpChannel->state(), SftpSession::State::Running);
QVERIFY(jobs.empty());
// Retrieve and check the attributes of the remote directory
QList<SftpFileInfo> remoteFileInfo;
const auto fileInfoHandler
= [&remoteFileInfo](SftpJobId, const QList<SftpFileInfo> &fileInfoList) {
remoteFileInfo << fileInfoList;
};
connect(sftpChannel.get(), &SftpSession::fileInfoAvailable, fileInfoHandler);
const SftpJobId statDirJob = sftpChannel->ls(remoteDirPath + "/..");
QVERIFY(statDirJob != SftpInvalidJob);
jobs << statDirJob;
loop.exec();
QVERIFY(!invalidFinishedSignal);
QVERIFY2(jobError.isEmpty(), qPrintable(jobError));
QCOMPARE(sftpChannel->state(), SftpSession::State::Running);
QVERIFY(jobs.empty());
QVERIFY(!remoteFileInfo.empty());
SftpFileInfo remoteDirInfo;
for (const SftpFileInfo &fi : qAsConst(remoteFileInfo)) {
if (fi.name == QFileInfo(remoteDirPath).fileName()) {
remoteDirInfo = fi;
break;
}
}
QCOMPARE(remoteDirInfo.type, FileTypeDirectory);
QCOMPARE(remoteDirInfo.name, QFileInfo(remoteDirPath).fileName());
// Retrieve and check the contents of the remote directory
remoteFileInfo.clear();
const SftpJobId lsDirJob = sftpChannel->ls(remoteDirPath);
QVERIFY(lsDirJob != SftpInvalidJob);
jobs << lsDirJob;
loop.exec();
QVERIFY(!invalidFinishedSignal);
QVERIFY2(jobError.isEmpty(), qPrintable(jobError));
QCOMPARE(sftpChannel->state(), SftpSession::State::Running);
QVERIFY(jobs.empty());
QCOMPARE(remoteFileInfo.size(), 0);
// Remove the remote directory.
const SftpJobId rmDirJob = sftpChannel->removeDirectory(remoteDirPath);
QVERIFY(rmDirJob != SftpInvalidJob);
jobs << rmDirJob;
loop.exec();
QVERIFY(!invalidFinishedSignal);
QVERIFY2(jobError.isEmpty(), qPrintable(jobError));
QCOMPARE(sftpChannel->state(), SftpSession::State::Running);
QVERIFY(jobs.empty());
// Closing down
sftpChannel->quit();
QCOMPARE(sftpChannel->state(), SftpSession::State::Closing);
loop.exec();
QVERIFY(!invalidFinishedSignal);
QVERIFY2(jobError.isEmpty(), qPrintable(jobError));
QCOMPARE(sftpChannel->state(), SftpSession::State::Inactive);
connect(&connection, &SshConnection::disconnected, &loop, &QEventLoop::quit);
timer.start();
connection.disconnectFromHost();
loop.exec();
QVERIFY(timer.isActive());
QCOMPARE(connection.state(), SshConnection::Unconnected);
QVERIFY2(connection.errorString().isEmpty(), qPrintable(connection.errorString()));
}
void tst_Ssh::cleanupTestCase()