#include "web/web_profile.h" #include "web/resource_policy.h" #include "web/renderer_watchdog.h" #include #include #include #include #include #include #include #include #include #include #include #ifdef Q_OS_LINUX #include #include #include #endif class WebTestReader : public QObject { Q_OBJECT Q_PROPERTY(QVariantMap settings READ settings NOTIFY settingsChanged) Q_PROPERTY(QString format READ format CONSTANT) Q_PROPERTY(QString context MEMBER context NOTIFY contextChanged) Q_PROPERTY(bool loaded READ loaded NOTIFY loadedChanged) Q_PROPERTY(int forbiddenConnections READ forbiddenConnections NOTIFY connectionsChanged) Q_PROPERTY(QVariantMap index READ index NOTIFY indexChanged) Q_PROPERTY(QVariantMap resourceIssues MEMBER resourceIssues NOTIFY resourceIssuesChanged) Q_PROPERTY(bool allowNetworkProbe MEMBER allowNetworkProbe) Q_PROPERTY(QUrl networkProbeUrl READ networkProbeUrl CONSTANT) Q_PROPERTY(int interceptedRequests MEMBER interceptedRequests NOTIFY resourceIssuesChanged) Q_PROPERTY(QString notice MEMBER notice NOTIFY noticeChanged) Q_PROPERTY(int watchdogFailures MEMBER watchdogFailures NOTIFY watchdogChanged) Q_PROPERTY(int heartbeatStarts MEMBER heartbeatStarts NOTIFY watchdogChanged) Q_PROPERTY(bool deferCommit MEMBER deferCommit) Q_PROPERTY(bool canPauseRenderers READ canPauseRenderers CONSTANT) Q_PROPERTY(bool holdLateResources MEMBER holdLateResources) Q_PROPERTY(int delayedResources READ delayedResources) public: explicit WebTestReader(QObject *parent = nullptr); ~WebTestReader() override { resumeRenderers(); } QVariantMap settings() const; QString format() const { return "html"; } bool loaded() const { return loaded_; } int forbiddenConnections() const { return connections_; } QVariantMap index() const { return index_; } QString context = "content", notice; QVariantMap resourceIssues; bool allowNetworkProbe = false; int interceptedRequests = 0; int watchdogFailures = 0, heartbeatStarts = 0; bool deferCommit = false; bool holdLateResources = false; int delayedResources() const { return delayedCompletions_.size(); } Q_INVOKABLE void releaseLateResources() { holdLateResources = false; const auto callbacks = std::exchange(delayedCompletions_, {}); for (const auto &done : callbacks) done(docview::ResourceFailure::None); } bool canPauseRenderers() const { #if defined(Q_OS_LINUX) && defined(SYS_pidfd_open) && defined(SYS_pidfd_send_signal) return true; #else return false; #endif } Q_INVOKABLE void registerRenderer(const QString &token, qint64 pid, int slot) { QString error; if (!watchdog_.registerRenderer(token, pid, &error, slot)) qFatal("Cannot monitor test renderer: %s", qPrintable(error)); #ifdef Q_OS_LINUX docview::RendererProcessInfo info; QFile stat(QStringLiteral("/proc/%1/stat").arg(pid)); if (pid > 0 && stat.open(QIODevice::ReadOnly) && docview::parseRendererProcStat(stat.readAll(), quint64(::sysconf(_SC_PAGESIZE)), &info)) identities_[token + ':' + QString::number(slot)] = {pid, info.startTime}; else identities_.remove(token + ':' + QString::number(slot)); #endif } Q_INVOKABLE quint32 beginRendererProbe(const QString &token, int slot) { const auto id = watchdog_.beginProbe(token, slot); if (id) { ++heartbeatStarts; emit watchdogChanged(); } return id; } Q_INVOKABLE void finishRendererProbe(const QString &token, int slot, quint32 probe) { watchdog_.acknowledgeProbe(token, slot, probe); } Q_INVOKABLE bool pauseRenderer(const QString &token, int slot) { #if defined(Q_OS_LINUX) && defined(SYS_pidfd_open) && defined(SYS_pidfd_send_signal) const auto identity = identities_.value(token + ':' + QString::number(slot)); if (identity.first <= 1) { qWarning() << "No registered renderer" << slot << watchdog_.monitoredCount(); return false; } // Only a renderer already accepted by the production ancestry/type // verifier may be paused, and this pidfd pins its original start time. const int fd = int(::syscall(SYS_pidfd_open, int(identity.first), 0)); if (fd < 0) { qWarning() << "pidfd_open failed" << errno; return false; } docview::RendererProcessInfo info; QFile stat(QStringLiteral("/proc/%1/stat").arg(identity.first)); const bool valid = stat.open(QIODevice::ReadOnly) && docview::parseRendererProcStat(stat.readAll(), quint64(::sysconf(_SC_PAGESIZE)), &info) && info.startTime == identity.second; if (!valid || ::syscall(SYS_pidfd_send_signal, fd, SIGSTOP, nullptr, 0) != 0) { qWarning() << "Pause refused" << valid << errno; ::close(fd); return false; } pausedHandles_.append(fd); return true; #else Q_UNUSED(token); Q_UNUSED(slot); return false; #endif } Q_INVOKABLE bool hasRenderer(const QString &token, int slot) const { return identities_.contains(token + ':' + QString::number(slot)); } Q_INVOKABLE void resumeRenderers() { #if defined(Q_OS_LINUX) && defined(SYS_pidfd_send_signal) for (const int fd : pausedHandles_) { ::syscall(SYS_pidfd_send_signal, fd, SIGCONT, nullptr, 0); ::close(fd); } pausedHandles_.clear(); #endif } Q_INVOKABLE void cancelPending() { if (pendingToken_.isEmpty() || pendingToken_ == activeToken_) return; const auto token = pendingToken_; pendingToken_.clear(); watchdog_.removeSession(token); handlers_[token]->revoke(); emit disposeWeb(token); loaded_ = !activeToken_.isEmpty(); emit loadedChanged(); } QUrl networkProbeUrl() const { return QUrl(QString("http://127.0.0.1:%1/probe").arg(server_.serverPort())); } Q_INVOKABLE void webResourceIssues(const QString &token, const QVariantMap &counts) { if (token != activeToken_ && token != pendingToken_) return; for (auto it = counts.begin(); it != counts.end(); ++it) resourceIssues[it.key()] = resourceIssues.value(it.key()).toInt() + it.value().toInt(); emit resourceIssuesChanged(); } Q_INVOKABLE QVariantMap webOptions(const QString &) const { return {{"fixed", fixed_}, {"rtl", rtl_}}; } Q_INVOKABLE void changeReading(int size, double height, bool publisher) { reading_ = {{"font_size", size}, {"line_height", height}, {"use_publisher_style", publisher}}; emit settingsChanged(); } Q_INVOKABLE QString script(const QString &) const; Q_INVOKABLE void open(const QString &relative); Q_INVOKABLE bool navigationAllowed(const QUrl &url, int type, const QString &token); Q_INVOKABLE void webLoaded(const QString &token, bool ok); Q_INVOKABLE void webIndex(const QString &, const QVariantMap &value) { index_ = value; emit indexChanged(); } Q_INVOKABLE void webLocation(const QString &, const QVariantMap &) {} Q_INVOKABLE void webStarted(const QString &, const QString &, quint64) {} Q_INVOKABLE void webApplied(const QString &, const QString &, quint64) {} Q_INVOKABLE void notify(const QString &message) { notice = message; emit noticeChanged(); } Q_INVOKABLE void renderStopped() { notify("Renderer stopped"); } Q_INVOKABLE void execute(const QString &, const QVariantMap & = {}) {} signals: void resourceIssuesChanged(); void watchdogChanged(); void contextChanged(); void settingsChanged(); void loadedChanged(); void indexChanged(); void noticeChanged(); void connectionsChanged(); void stageWeb(QQuickWebEngineProfile *profile, QUrl url, QString token); void activateWeb(QString token); void disposeWeb(QString token); void webCommand(QString id, QVariantMap args); private: QTemporaryDir root_; docview::RendererWatchdog watchdog_{nullptr, docview::RendererWatchdog::DefaultLimitBytes, {}, 1500}; QHash> identities_; QList pausedHandles_; QList delayedCompletions_; QTcpServer server_; QHash handlers_; QString activeToken_, pendingToken_; QVariantMap index_; QVariantMap reading_{{"font_size", 18}, {"line_height", 1.6}, {"use_publisher_style", true}}; bool fixed_ = false, rtl_ = false; bool loaded_ = false; int connections_ = 0; }; WebTestReader::WebTestReader(QObject *parent) : QObject(parent) { connect(&watchdog_, &docview::RendererWatchdog::responseTimedOut, this, [this](const QString &token, const QString &code) { ++watchdogFailures; if (handlers_.contains(token)) handlers_[token]->revoke(); if (token == activeToken_) activeToken_.clear(); if (token == pendingToken_) pendingToken_.clear(); emit disposeWeb(token); loaded_ = !activeToken_.isEmpty(); emit loadedChanged(); notify(code); emit watchdogChanged(); }); if (!server_.listen(QHostAddress::LocalHost, 0)) qFatal("Cannot open local observation server"); connect(&server_, &QTcpServer::newConnection, this, [this] { while (auto *socket = server_.nextPendingConnection()) { ++connections_; emit connectionsChanged(); socket->disconnectFromHost(); socket->deleteLater(); } }); auto write = [this](const QString &path, const QByteArray &bytes) { QFile file(root_.filePath(path)); if (!file.open(QIODevice::WriteOnly) || file.write(bytes) != bytes.size()) qFatal("Cannot write fixture"); }; write("style.css", "h1{color:rgb(17,34,51)} section{height:900px} .hidden{display:none}"); write("local.svg", ""); write("next.html", "

Unexpected refresh destination

"); write("base.html", "

First

Body

Second

End

"); write("headings.xhtml", "XHTML headings" "

Native first

Body

" "

Native second

Body

ARIA third
" "
Invalid level

End

"); if (!QFile::copy(root_.filePath("headings.xhtml"), root_.filePath("headings.html"))) qFatal("Cannot copy heading fixture"); const QByteArray clampedHeadings = "Clamped headings

First

Middle

" "

Last A

Last B

Short final section.

"; write("clamped-headings.xhtml", QByteArray(clampedHeadings).replace("CLASS", "")); write("clamped-headings-vertical.xhtml", QByteArray(clampedHeadings).replace("CLASS", "class='vertical'")); write("nested-headings.xhtml", "Nested headings

Outer

Inner first

" "

Inner last

End

"); QByteArray html = R"HTML(Web safety

First heading

SCRIPT BLOCKED

Font request must be blocked

Filler text.

Second heading

This is a stable text quote for position restoration.

ARIA heading
)HTML"; html.replace("PORT", QByteArray::number(server_.serverPort())); write("index.html", html); write("vertical.html", "

Vertical first

Vertical second

"); QByteArray reflow = "

Logical position

"; for (int i = 0; i < 100; ++i) { reflow += "

Paragraph " + QByteArray::number(i) + ": "; for (int j = 0; j < 5; ++j) reflow += "This distinct reading passage verifies the same logical text anchor after font and width changes. "; reflow += "

"; } write("reflow.html", reflow + ""); QByteArray late = reflow; late.replace("

Logical position

", "

Logical position

"); write("late-layout.html", late + ""); write("late-vertical.html", late + ""); write("late-image.svg", ""); if (!QFile::copy(QFileInfo(QString::fromUtf8(__FILE__)).absolutePath() + "/fixtures/web/late-layout.ttf", root_.filePath("late-layout.ttf"))) qFatal("Cannot copy generated test font"); write("fixed.html", "

Fixed viewport

Fixed text size

"); write("rtl.html", "

RTL first

مرحبا بالعالم

RTL second

النص الثاني

"); write("search.html", "

Find text

needle one

NEEDLE two

needle three

日本語 検索

日本語 検索

"); for (const auto &name : {QStringLiteral("space name.html"), QStringLiteral("日本語.html"), QStringLiteral("hash#percent%.html"), QStringLiteral("日本語 j k 123 #%.html")}) write(name, "" "

Encoded path

Correct document

Target

"); } QVariantMap WebTestReader::settings() const { return {{"reading", reading_}}; } QString WebTestReader::script(const QString &) const { QFile file(QFileInfo(QString::fromUtf8(__FILE__)).absolutePath() + "/../resources/reader.js"); return file.open(QIODevice::ReadOnly) ? QString::fromUtf8(file.readAll()) : QString(); } void WebTestReader::open(const QString &relative) { fixed_ = relative == "fixed.html"; rtl_ = relative == "rtl.html"; reading_ = {{"font_size", 18}, {"line_height", 1.6}, {"use_publisher_style", true}}; pendingToken_ = QUuid::createUuid().toString(QUuid::Id128); resourceIssues.clear(); emit resourceIssuesChanged(); allowNetworkProbe = false; interceptedRequests = 0; loaded_ = false; index_.clear(); emit loadedChanged(); auto *handler = new docview::ResourceHandler(pendingToken_, root_.path(), {}, [this](const QString &path, docview::ResourceHandler::Completion done) { if (holdLateResources && (path == "late-image.svg" || path == "late-layout.ttf")) delayedCompletions_.append(std::move(done)); else done(docview::ResourceFailure::None); }); connect(handler, &docview::ResourceHandler::blocked, this, [this](const QString &, const QString &category, int count) { if (category == "network") { interceptedRequests += count; emit resourceIssuesChanged(); } }); auto *profile = docview::createDocumentProfile(pendingToken_, handler, this); handlers_.insert(pendingToken_, handler); QUrl url; url.setScheme("doc"); url.setHost(pendingToken_); url.setPath("/" + relative); handler->authorizeTopLevel(url); emit stageWeb(profile, url, pendingToken_); } bool WebTestReader::navigationAllowed(const QUrl &url, int type, const QString &token) { // Test-only bypass of the UI navigation gate isolates the independent // profile interceptor. The production Controller has no such switch. if (allowNetworkProbe && url == networkProbeUrl()) return true; if (!handlers_.contains(token) || docview::pathFromDocumentUrl(url, token).isEmpty() || (type != 0 && type != 1 && type != 5)) return false; handlers_[token]->authorizeTopLevel(url); return true; } void WebTestReader::webLoaded(const QString &token, bool ok) { if (token != pendingToken_) return; if (ok && deferCommit) return; loaded_ = ok; if (ok) { const auto old = activeToken_; activeToken_ = token; pendingToken_.clear(); emit activateWeb(token); if (!old.isEmpty()) { handlers_[old]->revoke(); emit disposeWeb(old); } } emit loadedChanged(); } class WebTestSetup : public QObject { Q_OBJECT public slots: void qmlEngineAvailable(QQmlEngine *engine) { auto *reader = new WebTestReader(this); engine->rootContext()->setContextProperty("reader", reader); } }; int main(int argc, char **argv) { docview::registerDocumentScheme(); QtWebEngineQuick::initialize(); WebTestSetup setup; const auto directory = QFileInfo(QString::fromUtf8(__FILE__)).absolutePath() + "/webqml"; return quick_test_main_with_setup(argc, argv, "web_security", directory.toUtf8().constData(), &setup); } #include "test_web_qml.moc"