340 lines
14 KiB
C++
340 lines
14 KiB
C++
#include "common/single_instance.h"
|
|
|
|
#include <QCborArray>
|
|
#include <QCborMap>
|
|
#include <QFile>
|
|
#include <QLocalSocket>
|
|
#include <QLockFile>
|
|
#include <QProcess>
|
|
#include <QSignalSpy>
|
|
#include <QTemporaryDir>
|
|
#include <QTest>
|
|
#include <QtEndian>
|
|
#ifdef Q_OS_LINUX
|
|
#include <sys/socket.h>
|
|
#include <sys/stat.h>
|
|
#include <sys/un.h>
|
|
#include <unistd.h>
|
|
#endif
|
|
|
|
using namespace docview;
|
|
|
|
static QByteArray framed(const QCborMap &map)
|
|
{
|
|
const auto value = QCborValue(map).toCbor();
|
|
QByteArray bytes(4, '\0');
|
|
qToBigEndian(static_cast<quint32>(value.size()), bytes.data());
|
|
return bytes + value;
|
|
}
|
|
|
|
class InstanceTest : public QObject {
|
|
Q_OBJECT
|
|
private slots:
|
|
void forwardsToPrimary()
|
|
{
|
|
QTemporaryDir directory;
|
|
QVERIFY(directory.isValid());
|
|
SingleInstance primary;
|
|
QCOMPARE(primary.start(directory.path()), SingleInstance::StartResult::Primary);
|
|
QVERIFY(primary.isPrimary());
|
|
QSignalSpy opens(&primary, &SingleInstance::openRequested);
|
|
const auto path = directory.filePath("日本語 document.pdf");
|
|
QProcess second;
|
|
second.start(QCoreApplication::applicationFilePath(), {"--instance-forward", directory.path(), path});
|
|
QTRY_COMPARE_WITH_TIMEOUT(second.state(), QProcess::NotRunning, 5000);
|
|
QCOMPARE(second.exitStatus(), QProcess::NormalExit);
|
|
QCOMPARE(second.exitCode(), 0);
|
|
QCOMPARE(opens.size(), 1);
|
|
QCOMPARE(opens.first().first().toStringList(), QStringList{path});
|
|
QVERIFY(primary.isPrimary());
|
|
}
|
|
|
|
void activatesWithoutDocument()
|
|
{
|
|
QTemporaryDir directory;
|
|
SingleInstance primary;
|
|
QCOMPARE(primary.start(directory.path()), SingleInstance::StartResult::Primary);
|
|
QSignalSpy opens(&primary, &SingleInstance::openRequested);
|
|
QProcess second;
|
|
second.start(QCoreApplication::applicationFilePath(), {"--instance-forward", directory.path()});
|
|
QTRY_COMPARE_WITH_TIMEOUT(second.state(), QProcess::NotRunning, 5000);
|
|
QCOMPARE(second.exitCode(), 0);
|
|
QCOMPARE(opens.size(), 1);
|
|
QVERIFY(opens.first().first().toStringList().isEmpty());
|
|
}
|
|
|
|
void forwardsInitialPage_data()
|
|
{
|
|
QTest::addColumn<int>("page");
|
|
QTest::newRow("first") << 1;
|
|
QTest::newRow("page-42") << 42;
|
|
QTest::newRow("maximum") << 100000;
|
|
}
|
|
|
|
void forwardsInitialPage()
|
|
{
|
|
QFETCH(int, page);
|
|
QTemporaryDir directory;
|
|
SingleInstance primary;
|
|
QCOMPARE(primary.start(directory.path()), SingleInstance::StartResult::Primary);
|
|
QSignalSpy ordinaryOpens(&primary, &SingleInstance::openRequested);
|
|
QSignalSpy pageOpens(&primary, &SingleInstance::openAtPageRequested);
|
|
const auto path = directory.filePath("page 日本語.pdf");
|
|
QProcess second;
|
|
second.start(QCoreApplication::applicationFilePath(),
|
|
{"--instance-forward-page", directory.path(), QString::number(page), path});
|
|
QTRY_COMPARE_WITH_TIMEOUT(second.state(), QProcess::NotRunning, 5000);
|
|
QCOMPARE(second.exitStatus(), QProcess::NormalExit);
|
|
QCOMPARE(second.exitCode(), 0);
|
|
QCOMPARE(ordinaryOpens.size(), 0);
|
|
QCOMPARE(pageOpens.size(), 1);
|
|
QCOMPARE(pageOpens.front()[0].toString(), path);
|
|
QCOMPARE(pageOpens.front()[1].toInt(), page);
|
|
}
|
|
|
|
void lockedButUnavailableIsReadOnly()
|
|
{
|
|
QTemporaryDir directory;
|
|
QLockFile owner(directory.filePath("instance.lock"));
|
|
QVERIFY(owner.tryLock(0));
|
|
SingleInstance second;
|
|
QCOMPARE(second.start(directory.path(), {}, 100), SingleInstance::StartResult::ReadOnly);
|
|
QVERIFY(!second.isPrimary());
|
|
QVERIFY(!second.lastError().isEmpty());
|
|
QVERIFY(owner.isLocked());
|
|
}
|
|
|
|
void rejectsMalformedRequests_data()
|
|
{
|
|
QTest::addColumn<QByteArray>("bytes");
|
|
QTest::newRow("relative") << framed({{"version", 1}, {"operation", "open"}, {"paths", QCborArray{"relative.pdf"}}});
|
|
QTest::newRow("url") << framed({{"version", 1}, {"operation", "open"}, {"paths", QCborArray{"https://example.org/document.pdf"}}});
|
|
QTest::newRow("unc") << framed({{"version", 1}, {"operation", "open"}, {"paths", QCborArray{"//server/share/file.pdf"}}});
|
|
QTest::newRow("resource") << framed({{"version", 1}, {"operation", "open"}, {"paths", QCborArray{":/secret"}}});
|
|
QTest::newRow("version") << framed({{"version", 2}, {"operation", "open"}, {"paths", QCborArray{}}});
|
|
QTest::newRow("type") << framed({{"version", 1}, {"operation", "open"}, {"paths", QCborArray{42}}});
|
|
QTest::newRow("operation") << framed({{"version", 1}, {"operation", "execute"}, {"paths", QCborArray{}}});
|
|
QTest::newRow("unknown-key") << framed({{"version", 1}, {"operation", "open"}, {"paths", QCborArray{}}, {"shell", "touch"}});
|
|
const QCborArray path{QDir::temp().absoluteFilePath("one.pdf")};
|
|
const auto pageRequest = [&](const QCborValue &page, const QCborArray &paths) {
|
|
return framed({{"version", 1}, {"operation", "open"}, {"paths", paths}, {"initialPage", page}});
|
|
};
|
|
QTest::newRow("page-string") << pageRequest("42", path);
|
|
QTest::newRow("page-double") << pageRequest(42.0, path);
|
|
QTest::newRow("page-bool") << pageRequest(true, path);
|
|
QTest::newRow("page-zero") << pageRequest(0, path);
|
|
QTest::newRow("page-negative") << pageRequest(-1, path);
|
|
QTest::newRow("page-too-large") << pageRequest(100001, path);
|
|
QTest::newRow("page-no-path") << pageRequest(42, {});
|
|
QTest::newRow("page-two-paths") << pageRequest(42, {path.at(0), QDir::temp().absoluteFilePath("two.pdf")});
|
|
QByteArray tooLarge(4, '\0');
|
|
qToBigEndian<quint32>(8193, tooLarge.data());
|
|
QTest::newRow("oversize-header") << tooLarge;
|
|
QTest::newRow("empty-frame") << QByteArray(4, '\0');
|
|
QByteArray invalid(4, '\0');
|
|
qToBigEndian<quint32>(1, invalid.data());
|
|
invalid.append(char(0xff));
|
|
QTest::newRow("invalid-cbor") << invalid;
|
|
}
|
|
|
|
void rejectsMalformedRequests()
|
|
{
|
|
QFETCH(QByteArray, bytes);
|
|
QTemporaryDir directory;
|
|
SingleInstance primary;
|
|
QCOMPARE(primary.start(directory.path()), SingleInstance::StartResult::Primary);
|
|
QSignalSpy opens(&primary, &SingleInstance::openRequested);
|
|
QSignalSpy pageOpens(&primary, &SingleInstance::openAtPageRequested);
|
|
QLocalSocket socket;
|
|
socket.connectToServer(primary.endpoint());
|
|
QTRY_COMPARE(socket.state(), QLocalSocket::ConnectedState);
|
|
QCOMPARE(socket.write(bytes), bytes.size());
|
|
socket.flush();
|
|
QTRY_COMPARE_WITH_TIMEOUT(socket.state(), QLocalSocket::UnconnectedState, 3000);
|
|
QCOMPARE(opens.size(), 0);
|
|
QCOMPARE(pageOpens.size(), 0);
|
|
QVERIFY(primary.isPrimary());
|
|
}
|
|
|
|
void partialFrameTimesOut()
|
|
{
|
|
QTemporaryDir directory;
|
|
SingleInstance primary;
|
|
QCOMPARE(primary.start(directory.path()), SingleInstance::StartResult::Primary);
|
|
QSignalSpy opens(&primary, &SingleInstance::openRequested);
|
|
QLocalSocket socket;
|
|
socket.connectToServer(primary.endpoint());
|
|
QTRY_COMPARE(socket.state(), QLocalSocket::ConnectedState);
|
|
const auto frame = framed({{"version", 1}, {"operation", "open"}, {"paths", QCborArray{}}});
|
|
socket.write(frame.left(5));
|
|
socket.flush();
|
|
QTRY_COMPARE_WITH_TIMEOUT(socket.state(), QLocalSocket::UnconnectedState, 2500);
|
|
QCOMPARE(opens.size(), 0);
|
|
}
|
|
|
|
void rejectsInvalidOutboundPaths()
|
|
{
|
|
QTemporaryDir directory;
|
|
for (const auto &path : {QString("relative.pdf"), QString("https://example.org/a"), QString(":/resource"), QString("//server/file")}) {
|
|
SingleInstance instance;
|
|
QCOMPARE(instance.start(directory.path(), {path}), SingleInstance::StartResult::ReadOnly);
|
|
QVERIFY(!instance.isPrimary());
|
|
}
|
|
}
|
|
|
|
void rejectsInvalidOutboundPages_data()
|
|
{
|
|
QTest::addColumn<int>("page");
|
|
QTest::addColumn<int>("pathCount");
|
|
QTest::newRow("negative") << -1 << 1;
|
|
QTest::newRow("too-large") << 100001 << 1;
|
|
QTest::newRow("no-path") << 42 << 0;
|
|
QTest::newRow("two-paths") << 42 << 2;
|
|
}
|
|
|
|
void rejectsInvalidOutboundPages()
|
|
{
|
|
QFETCH(int, page);
|
|
QFETCH(int, pathCount);
|
|
QTemporaryDir directory;
|
|
QStringList paths;
|
|
for (int i = 0; i < pathCount; ++i)
|
|
paths << directory.filePath(QString::number(i) + ".pdf");
|
|
SingleInstance second;
|
|
QCOMPARE(second.start(directory.path(), paths, 100, page), SingleInstance::StartResult::ReadOnly);
|
|
QVERIFY(!second.lastError().isEmpty());
|
|
QVERIFY(!second.isPrimary());
|
|
// Validation happens before creating a lock or listening endpoint.
|
|
SingleInstance primary;
|
|
QCOMPARE(primary.start(directory.path()), SingleInstance::StartResult::Primary);
|
|
}
|
|
|
|
void safeRestartAndLongDirectory()
|
|
{
|
|
QTemporaryDir base;
|
|
const auto directory = base.filePath(QString(100, 'x') + "/" + QString(100, 'y'));
|
|
{
|
|
SingleInstance primary;
|
|
QCOMPARE(primary.start(directory), SingleInstance::StartResult::Primary);
|
|
QVERIFY(primary.isPrimary());
|
|
}
|
|
SingleInstance replacement;
|
|
QCOMPARE(replacement.start(directory), SingleInstance::StartResult::Primary);
|
|
}
|
|
|
|
#ifdef Q_OS_LINUX
|
|
void rejectsPublicDirectoryAndLinks()
|
|
{
|
|
QTemporaryDir base;
|
|
const auto publicDirectory = base.filePath("public");
|
|
QVERIFY(QDir().mkpath(publicDirectory));
|
|
QVERIFY(QFile::setPermissions(publicDirectory, QFileDevice::ReadOwner | QFileDevice::WriteOwner
|
|
| QFileDevice::ExeOwner | QFileDevice::ReadGroup | QFileDevice::ExeGroup));
|
|
SingleInstance publicInstance;
|
|
QCOMPARE(publicInstance.start(publicDirectory), SingleInstance::StartResult::ReadOnly);
|
|
const auto link = base.filePath("link");
|
|
QVERIFY(QFile::link(base.path(), link));
|
|
SingleInstance linked;
|
|
QCOMPARE(linked.start(link), SingleInstance::StartResult::ReadOnly);
|
|
const auto lockTarget = base.filePath("target");
|
|
QFile file(lockTarget);
|
|
QVERIFY(file.open(QIODevice::WriteOnly));
|
|
file.write("must survive"); file.close();
|
|
QVERIFY(QFile::link(lockTarget, base.filePath("instance.lock")));
|
|
SingleInstance locked;
|
|
QCOMPARE(locked.start(base.path()), SingleInstance::StartResult::ReadOnly);
|
|
QVERIFY(QFileInfo(base.filePath("instance.lock")).isSymLink());
|
|
QVERIFY(file.open(QIODevice::ReadOnly));
|
|
QCOMPARE(file.readAll(), QByteArray("must survive"));
|
|
}
|
|
|
|
void neverDeletesUnrelatedEndpoint()
|
|
{
|
|
QTemporaryDir directory;
|
|
const auto endpoint = directory.filePath("i");
|
|
QFile file(endpoint);
|
|
QVERIFY(file.open(QIODevice::WriteOnly));
|
|
file.write("unrelated"); file.close();
|
|
{
|
|
SingleInstance instance;
|
|
QCOMPARE(instance.start(directory.path()), SingleInstance::StartResult::ReadOnly);
|
|
}
|
|
QVERIFY(file.open(QIODevice::ReadOnly));
|
|
QCOMPARE(file.readAll(), QByteArray("unrelated"));
|
|
file.close();
|
|
QVERIFY(file.remove());
|
|
{
|
|
SingleInstance instance;
|
|
QCOMPARE(instance.start(directory.path()), SingleInstance::StartResult::Primary);
|
|
QVERIFY(QFile::remove(endpoint));
|
|
QVERIFY(file.open(QIODevice::WriteOnly));
|
|
file.write("replacement"); file.close();
|
|
}
|
|
QVERIFY(file.open(QIODevice::ReadOnly));
|
|
QCOMPARE(file.readAll(), QByteArray("replacement"));
|
|
}
|
|
|
|
void recoversOwnedStaleSocket()
|
|
{
|
|
QTemporaryDir directory;
|
|
const int descriptor = ::socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0);
|
|
QVERIFY(descriptor >= 0);
|
|
sockaddr_un address{};
|
|
address.sun_family = AF_UNIX;
|
|
const auto path = QFile::encodeName(directory.filePath("i"));
|
|
QVERIFY(path.size() < static_cast<int>(sizeof(address.sun_path)));
|
|
std::copy(path.cbegin(), path.cend(), address.sun_path);
|
|
QCOMPARE(::bind(descriptor, reinterpret_cast<sockaddr *>(&address), sizeof(address)), 0);
|
|
::close(descriptor);
|
|
SingleInstance instance;
|
|
QCOMPARE(instance.start(directory.path()), SingleInstance::StartResult::Primary);
|
|
struct stat details{};
|
|
QCOMPARE(::lstat(path.constData(), &details), 0);
|
|
QVERIFY(S_ISSOCK(details.st_mode));
|
|
QCOMPARE(details.st_mode & 0077, 0u);
|
|
}
|
|
|
|
void cleanupUsesPinnedDirectoryInode()
|
|
{
|
|
QTemporaryDir base;
|
|
const auto original = base.filePath("state");
|
|
const auto renamed = base.filePath("renamed");
|
|
SingleInstance instance;
|
|
QCOMPARE(instance.start(original), SingleInstance::StartResult::Primary);
|
|
QVERIFY(QDir().rename(original, renamed));
|
|
QVERIFY(QDir().mkpath(original));
|
|
QFile replacement(original + "/i");
|
|
QVERIFY(replacement.open(QIODevice::WriteOnly));
|
|
replacement.write("different directory"); replacement.close();
|
|
instance.stop();
|
|
QVERIFY(!QFileInfo::exists(renamed + "/i"));
|
|
QVERIFY(replacement.open(QIODevice::ReadOnly));
|
|
QCOMPARE(replacement.readAll(), QByteArray("different directory"));
|
|
}
|
|
#endif
|
|
};
|
|
|
|
int main(int argc, char **argv)
|
|
{
|
|
QCoreApplication application(argc, argv);
|
|
const auto arguments = application.arguments();
|
|
if (arguments.value(1) == "--instance-forward") {
|
|
SingleInstance instance;
|
|
const auto result = instance.start(arguments.value(2), arguments.mid(3), 1500);
|
|
return result == SingleInstance::StartResult::Forwarded ? 0 : 3;
|
|
}
|
|
if (arguments.value(1) == "--instance-forward-page") {
|
|
bool ok = false;
|
|
const int page = arguments.value(3).toInt(&ok);
|
|
if (!ok)
|
|
return 3;
|
|
SingleInstance instance;
|
|
const auto result = instance.start(arguments.value(2), arguments.mid(4), 1500, page);
|
|
return result == SingleInstance::StartResult::Forwarded ? 0 : 3;
|
|
}
|
|
InstanceTest test;
|
|
return QTest::qExec(&test, argc, argv);
|
|
}
|
|
|
|
#include "test_instance.moc"
|