199 lines
12 KiB
C++
199 lines
12 KiB
C++
// Compile/run only on native Windows with the production LPAC bootstrap enabled.
|
|
#include <QtGlobal>
|
|
#ifndef NOMINMAX
|
|
#define NOMINMAX
|
|
#endif
|
|
#include <windows.h>
|
|
#include "common/worker_process.h"
|
|
#include <QCoreApplication>
|
|
#include <QDir>
|
|
#include <QFile>
|
|
#include <QFileInfo>
|
|
#include <QJsonArray>
|
|
#include <QJsonDocument>
|
|
#include <QJsonObject>
|
|
#include <QSignalSpy>
|
|
#include <QTcpServer>
|
|
#include <QTcpSocket>
|
|
#include <QTemporaryDir>
|
|
#include <QTest>
|
|
#include <vector>
|
|
|
|
using namespace docview;
|
|
namespace {
|
|
struct Fixture {
|
|
QTemporaryDir directory{QDir::tempPath() + "/docview-win-probe-XXXXXX"};
|
|
QString source;
|
|
QByteArray sourceBytes;
|
|
std::vector<HANDLE> sentinels;
|
|
~Fixture() { for (auto handle : sentinels) CloseHandle(handle); }
|
|
bool prepare(const QString &mode, quint16 port = 0) {
|
|
if (!directory.isValid()) return false;
|
|
source = directory.filePath("source.json");
|
|
if (!QDir().mkpath(directory.filePath("config")) || !QDir().mkpath(directory.filePath("cache"))) return false;
|
|
const auto put = [](const QString &path, const QByteArray &bytes) {
|
|
QFile file(path); return file.open(QIODevice::WriteOnly) && file.write(bytes) == bytes.size();
|
|
};
|
|
const auto sibling = directory.filePath("neighbor.txt"), config = directory.filePath("config/user-settings.json");
|
|
if (!put(sibling, "TEMPORARY NEIGHBOR FIXTURE") || !put(config, "{\"privateFixture\":true}")) return false;
|
|
QJsonArray handles;
|
|
for (int i = 0; i < 2; ++i) {
|
|
const auto path = directory.filePath(QString("sentinel-%1.txt").arg(i));
|
|
if (!put(path, "TEMPORARY INHERITANCE SENTINEL")) return false;
|
|
SECURITY_ATTRIBUTES attributes{sizeof(attributes), nullptr, TRUE};
|
|
const auto name = QDir::toNativeSeparators(path).toStdWString();
|
|
const auto handle = CreateFileW(name.c_str(), GENERIC_READ, FILE_SHARE_READ, &attributes, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
|
|
if (handle == INVALID_HANDLE_VALUE) return false;
|
|
sentinels.push_back(handle);
|
|
BY_HANDLE_FILE_INFORMATION info{}; DWORD flags = 0;
|
|
if (!GetFileInformationByHandle(handle, &info) || !GetHandleInformation(handle, &flags) || !(flags & HANDLE_FLAG_INHERIT)) return false;
|
|
handles.append(QJsonObject{{"value", QString::number(reinterpret_cast<quintptr>(handle))},
|
|
{"volume", qint64(info.dwVolumeSerialNumber)}, {"high", qint64(info.nFileIndexHigh)}, {"low", qint64(info.nFileIndexLow)}});
|
|
}
|
|
const QJsonObject input{{"mode", mode}, {"fixtureRoot", directory.path()}, {"sourcePath", source},
|
|
{"siblingPath", sibling}, {"configPath", config}, {"outputPath", directory.filePath("cache/worker-output.bin")},
|
|
{"loopbackPort", int(port)}, {"sentinelHandles", handles}};
|
|
sourceBytes = QJsonDocument(input).toJson(QJsonDocument::Compact);
|
|
return put(source, sourceBytes);
|
|
}
|
|
};
|
|
QString probeExecutable() {
|
|
return QDir(QCoreApplication::applicationDirPath()).filePath("docview-windows-worker-probe.exe");
|
|
}
|
|
bool launch(WorkerProcess &worker, const Fixture &fixture) {
|
|
return worker.start(probeExecutable(), {"--source", fixture.source});
|
|
}
|
|
}
|
|
|
|
class WindowsWorkerTests : public QObject {
|
|
Q_OBJECT
|
|
private slots:
|
|
void initTestCase() {
|
|
QVERIFY2(QFileInfo::exists(probeExecutable()), "Build/deploy docview-windows-worker-probe with its generated LPAC-compatible manifest and Qt dependencies");
|
|
// A missing/unavailable sandbox is a failed native release gate. No
|
|
// skip or unrestricted-worker fallback is accepted in this suite.
|
|
}
|
|
void protectedReadinessAndReplyOrder() {
|
|
Fixture fixture; QVERIFY(fixture.prepare("echo"));
|
|
WorkerProcess worker("native-session", 37);
|
|
QSignalSpy ready(&worker, &WorkerProcess::ready), failures(&worker, &WorkerProcess::failed);
|
|
QList<QCborMap> replies;
|
|
QVERIFY(launch(worker, fixture));
|
|
worker.request("render", {{"ordinal", 1}}, [&](const QCborMap &r) { replies << r; });
|
|
worker.request("metadata", {{"ordinal", 2}}, [&](const QCborMap &r) { replies << r; });
|
|
QTRY_COMPARE_WITH_TIMEOUT(replies.size(), 2, 20000);
|
|
QCOMPARE(ready.size(), 1); QCOMPARE(failures.size(), 0);
|
|
for (int i = 0; i < 2; ++i) {
|
|
QCOMPARE(replies[i].value("requestId").toInteger(), i + 2); // first ping is bootstrap
|
|
QCOMPARE(replies[i].value("sessionId").toString(), "native-session");
|
|
QCOMPARE(replies[i].value("generation").toInteger(), 37);
|
|
const auto result = replies[i].value("result").toMap();
|
|
QCOMPARE(result.value("received").toInteger(), i + 2);
|
|
QCOMPARE(result.value("echo").toMap().value("ordinal").toInteger(), i + 1);
|
|
QVERIFY(result.value("sandboxed").toBool());
|
|
}
|
|
QVERIFY(worker.idle()); worker.stop(); QCOMPARE(failures.size(), 0);
|
|
}
|
|
void rejectsHostileRepliesAfterReady_data() {
|
|
QTest::addColumn<QString>("mode");
|
|
for (const auto *mode : {"wrong-session", "wrong-generation", "wrong-request", "wrong-operation", "wrong-version", "oversize", "illegal-more"})
|
|
QTest::newRow(mode) << QString::fromLatin1(mode);
|
|
}
|
|
void rejectsHostileRepliesAfterReady() {
|
|
QFETCH(QString, mode); Fixture fixture; QVERIFY(fixture.prepare(mode));
|
|
WorkerProcess worker("protected-session", 7);
|
|
QSignalSpy ready(&worker, &WorkerProcess::ready), failures(&worker, &WorkerProcess::failed);
|
|
int callbacks = 0;
|
|
QVERIFY(launch(worker, fixture)); QTRY_COMPARE_WITH_TIMEOUT(ready.size(), 1, 15000);
|
|
worker.request("render", {}, [&](const QCborMap &) { ++callbacks; });
|
|
worker.request("metadata", {}, [&](const QCborMap &) { ++callbacks; });
|
|
QTRY_COMPARE_WITH_TIMEOUT(failures.size(), 1, 10000);
|
|
QCOMPARE(failures.first()[0].toString(), "E_WORKER_CRASH");
|
|
QCOMPARE(callbacks, 0); QVERIFY(worker.idle());
|
|
QTest::qWait(100); QCOMPARE(failures.size(), 1);
|
|
}
|
|
void classifiesProtectedExit_data() {
|
|
QTest::addColumn<QString>("mode"); QTest::addColumn<QString>("code");
|
|
QTest::newRow("ordinary") << QString("crash") << QString("E_WORKER_CRASH");
|
|
QTest::newRow("sandbox") << QString("sandbox-unavailable") << QString("E_SANDBOX_UNAVAILABLE");
|
|
}
|
|
void classifiesProtectedExit() {
|
|
QFETCH(QString, mode); QFETCH(QString, code); Fixture fixture; QVERIFY(fixture.prepare(mode));
|
|
WorkerProcess worker("exit-session", 1); QSignalSpy ready(&worker, &WorkerProcess::ready), failures(&worker, &WorkerProcess::failed);
|
|
int callbacks = 0;
|
|
QVERIFY(launch(worker, fixture)); QTRY_COMPARE_WITH_TIMEOUT(ready.size(), 1, 15000);
|
|
worker.request("render", {}, [&](const QCborMap &) { ++callbacks; });
|
|
QTRY_COMPARE_WITH_TIMEOUT(failures.size(), 1, 10000);
|
|
QCOMPARE(failures.first()[0].toString(), code); QCOMPARE(callbacks, 0);
|
|
QTest::qWait(100); QCOMPARE(failures.size(), 1);
|
|
}
|
|
void normalErrorAndStreamKeepProtocolUsable() {
|
|
Fixture fixture; QVERIFY(fixture.prepare("stream"));
|
|
WorkerProcess worker("stream-session", 3); QSignalSpy failures(&worker, &WorkerProcess::failed);
|
|
QByteArray bytes; QStringList order; bool final = false;
|
|
QVERIFY(launch(worker, fixture));
|
|
worker.request("extract", {{"path", "fixture"}}, [&](const QCborMap &r) {
|
|
const auto result = r.value("result").toMap();
|
|
if (result.value("more").toBool()) { QVERIFY(!worker.idle()); QVERIFY(!final); bytes += result.value("data").toByteArray(); order << "chunk"; }
|
|
else { final = true; QCOMPARE(result.value("size").toInteger(), 9); order << "final"; }
|
|
});
|
|
worker.request("metadata", {}, [&](const QCborMap &r) {
|
|
QVERIFY(final); QVERIFY(!r.value("result").toMap().value("receivedDuringStream").toBool()); order << "next";
|
|
});
|
|
QTRY_COMPARE_WITH_TIMEOUT(order.size(), 4, 20000);
|
|
QCOMPARE(bytes, QByteArray("alphabeta")); QCOMPARE(order, (QStringList{"chunk", "chunk", "final", "next"})); QCOMPARE(failures.size(), 0);
|
|
worker.stop();
|
|
Fixture errors; QVERIFY(errors.prepare("error")); WorkerProcess errorWorker("errors", 1);
|
|
QSignalSpy errorFailures(&errorWorker, &WorkerProcess::failed); QList<QCborMap> replies;
|
|
QVERIFY(launch(errorWorker, errors)); errorWorker.request("open", {}, [&](const QCborMap &r) { replies << r; });
|
|
QTRY_COMPARE_WITH_TIMEOUT(replies.size(), 1, 20000);
|
|
QCOMPARE(replies.first().value("error").toMap().value("code").toString(), "E_FIXTURE"); QCOMPARE(errorFailures.size(), 0);
|
|
}
|
|
void actualLpacOsBoundaries() {
|
|
QTcpServer listener; QVERIFY(listener.listen(QHostAddress::LocalHost, 0));
|
|
QSignalSpy connections(&listener, &QTcpServer::newConnection);
|
|
Fixture fixture; QVERIFY(fixture.prepare("probe-os", listener.serverPort()));
|
|
WorkerProcess worker("boundary-session", 9); QSignalSpy ready(&worker, &WorkerProcess::ready), failures(&worker, &WorkerProcess::failed);
|
|
QList<QCborMap> replies;
|
|
QVERIFY(launch(worker, fixture)); worker.request("probe", {}, [&](const QCborMap &r) { replies << r; });
|
|
QTRY_COMPARE_WITH_TIMEOUT(replies.size(), 1, 25000);
|
|
QCOMPARE(ready.size(), 1); QCOMPARE(failures.size(), 0);
|
|
const auto result = replies.first().value("result").toMap();
|
|
for (const auto *key : {"fixture_valid", "source_handle_write_denied", "source_reopen_write_denied", "parent_read_denied",
|
|
"sibling_read_denied", "config_read_denied", "arbitrary_output_denied", "extra_file_handles_absent", "loopback_denied",
|
|
"child_creation_denied", "own_profile_write_denied", "own_profile_registry_write_denied"}) {
|
|
QVERIFY2(result.value(QString::fromLatin1(key)).toBool(), qPrintable(QString::fromLatin1(key) + ": " + QString::fromUtf8(QJsonDocument::fromVariant(result.toVariantMap()).toJson(QJsonDocument::Compact))));
|
|
}
|
|
QCOMPARE(connections.size(), 0); worker.stop();
|
|
QFile source(fixture.source); QVERIFY(source.open(QIODevice::ReadOnly)); QCOMPARE(source.readAll(), fixture.sourceBytes);
|
|
QVERIFY(!QFileInfo::exists(fixture.directory.filePath("cache/worker-output.bin")));
|
|
}
|
|
void gracefulCloseRequiresReply_data() {
|
|
QTest::addColumn<bool>("acknowledged");
|
|
QTest::newRow("reply-before-exit") << true;
|
|
QTest::newRow("exit-without-reply") << false;
|
|
}
|
|
void gracefulCloseRequiresReply() {
|
|
QFETCH(bool, acknowledged);
|
|
Fixture fixture; QVERIFY(fixture.prepare(acknowledged ? "close-reply-exit" : "close-without-reply"));
|
|
WorkerProcess worker("close-session", 1);
|
|
QSignalSpy ready(&worker, &WorkerProcess::ready), failures(&worker, &WorkerProcess::failed);
|
|
int closed = 0, afterClose = 0;
|
|
QVERIFY(launch(worker, fixture)); QTRY_COMPARE_WITH_TIMEOUT(ready.size(), 1, 15000);
|
|
worker.request("close", {}, [&](const QCborMap &reply) {
|
|
QVERIFY(reply.value("result").toMap().value("closed").toBool()); ++closed;
|
|
});
|
|
worker.request("metadata", {}, [&](const QCborMap &) { ++afterClose; });
|
|
if (acknowledged) {
|
|
QTRY_COMPARE_WITH_TIMEOUT(closed, 1, 10000);
|
|
QTest::qWait(100); QCOMPARE(failures.size(), 0);
|
|
} else {
|
|
QTRY_COMPARE_WITH_TIMEOUT(failures.size(), 1, 10000);
|
|
QCOMPARE(failures.first()[0].toString(), "E_WORKER_CRASH"); QCOMPARE(closed, 0);
|
|
}
|
|
QCOMPARE(afterClose, 0); QVERIFY(worker.idle()); QVERIFY(!worker.isConnected());
|
|
}
|
|
};
|
|
QTEST_GUILESS_MAIN(WindowsWorkerTests)
|
|
#include "test_windows_worker.moc"
|