#include "common/single_instance.h" #include #include #include #include #include #include #include #include #include #include #ifdef Q_OS_LINUX #include #include #include #include #endif using namespace docview; static QByteArray framed(const QCborMap &map) { const auto value = QCborValue(map).toCbor(); QByteArray bytes(4, '\0'); qToBigEndian(static_cast(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("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("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(8193, tooLarge.data()); QTest::newRow("oversize-header") << tooLarge; QTest::newRow("empty-frame") << QByteArray(4, '\0'); QByteArray invalid(4, '\0'); qToBigEndian(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("page"); QTest::addColumn("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(sizeof(address.sun_path))); std::copy(path.cbegin(), path.cend(), address.sun_path); QCOMPARE(::bind(descriptor, reinterpret_cast(&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"