initial commit
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
#include "web/web_profile.h"
|
||||
#include "web/resource_policy.h"
|
||||
#include "web/renderer_watchdog.h"
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QDir>
|
||||
#include <QQmlEngine>
|
||||
#include <QQmlContext>
|
||||
#include <QTcpServer>
|
||||
#include <QTcpSocket>
|
||||
#include <QTemporaryDir>
|
||||
#include <QUuid>
|
||||
#include <QtQuickTest/quicktest.h>
|
||||
#include <QtWebEngineQuick>
|
||||
#ifdef Q_OS_LINUX
|
||||
#include <sys/syscall.h>
|
||||
#include <signal.h>
|
||||
#include <unistd.h>
|
||||
#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<QString, QPair<qint64, quint64>> identities_;
|
||||
QList<int> pausedHandles_;
|
||||
QList<docview::ResourceHandler::Completion> delayedCompletions_;
|
||||
QTcpServer server_;
|
||||
QHash<QString, docview::ResourceHandler *> 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", "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"80\" height=\"40\"><rect width=\"80\" height=\"40\" fill=\"green\"/></svg>");
|
||||
write("next.html", "<h1>Unexpected refresh destination</h1>");
|
||||
write("base.html", "<html><head><base href='folder/other.html'></head><body><h1 id='one'>First</h1><nav role='doc-toc'><a href='#two'>Authored</a></nav><p style='height:1200px'>Body</p><h2 id='two'>Second</h2><p style='height:1200px'>End</p></body></html>");
|
||||
write("headings.xhtml", "<html xmlns='http://www.w3.org/1999/xhtml' xmlns:epub='http://www.idpf.org/2007/ops'><head><title>XHTML headings</title></head><body>"
|
||||
"<h1 id='one'>Native first</h1><nav epub:type='toc'><ol><li><a href='#one'>Parent</a><ul><li><a href='#two'>Child</a>"
|
||||
"<ol><li><a href='#aria'>Grandchild</a></li></ol></li></ul></li></ol></nav><p style='height:1200px'>Body</p>"
|
||||
"<h2 id='two'>Native second</h2><p style='height:1200px'>Body</p><div id='aria' role='heading' aria-level='3'>ARIA third</div>"
|
||||
"<div role='heading' aria-level='0'>Invalid level</div><h2 hidden='hidden'>Hidden heading</h2><p style='height:1200px'>End</p></body></html>");
|
||||
if (!QFile::copy(root_.filePath("headings.xhtml"), root_.filePath("headings.html"))) qFatal("Cannot copy heading fixture");
|
||||
const QByteArray clampedHeadings = "<html xmlns='http://www.w3.org/1999/xhtml'><head><title>Clamped headings</title><style>"
|
||||
"html,body{margin:0;padding:0}h1,h2,p{margin:0;font:20px/1 sans-serif}.gap{block-size:1200px}body.vertical{writing-mode:vertical-rl;height:500px}"
|
||||
"</style></head><body CLASS><h1>First</h1><div class='gap'></div><h2>Middle</h2><div class='gap'></div>"
|
||||
"<h2>Last A</h2><h2>Last B</h2><p>Short final section.</p></body></html>";
|
||||
write("clamped-headings.xhtml", QByteArray(clampedHeadings).replace("CLASS", ""));
|
||||
write("clamped-headings-vertical.xhtml", QByteArray(clampedHeadings).replace("CLASS", "class='vertical'"));
|
||||
write("nested-headings.xhtml", "<html xmlns='http://www.w3.org/1999/xhtml'><head><title>Nested headings</title><style>"
|
||||
"html,body{margin:0;padding:0}h1,h2,p{margin:0;font:20px/1 sans-serif}section{height:200px;overflow:auto}"
|
||||
"</style></head><body><h1>Outer</h1><section id='scroller'><h2>Inner first</h2><div style='height:800px'></div>"
|
||||
"<h2>Inner last</h2><p style='height:250px'>End</p></section></body></html>");
|
||||
QByteArray html = R"HTML(<!doctype html><html><head><meta charset="UTF-8"><title>Web safety</title><link rel="stylesheet" href="style.css"><link rel="stylesheet" href="http://127.0.0.1:PORT/outside.css"><style>@font-face{font-family:outside;src:url(http://127.0.0.1:PORT/font.woff)}#font-probe{font-family:outside}</style><meta http-equiv="refresh" content="1;url=next.html"></head><body><h1 id="first">First heading</h1><nav role="doc-toc"><a href="#second">Second heading</a></nav><img id="local-image" src="local.svg"><img src="http://127.0.0.1:PORT/image"><img src="file:///etc/passwd"><img src="doc://other-document/local.svg"><p id="marker">SCRIPT BLOCKED</p><p id="font-probe">Font request must be blocked</p><script>window.executed=true;document.getElementById('marker').textContent='SCRIPT RAN';fetch('http://127.0.0.1:PORT/script');</script><iframe src="http://127.0.0.1:PORT/frame"></iframe><iframe src="file:///etc/passwd"></iframe><form action="http://127.0.0.1:PORT/form"><input id="input" value="ORIGINAL"><button id="submit">Send</button></form><section><h2 class="hidden">Hidden heading</h2><p>Filler text.</p></section><h2 id="second">Second heading</h2><p>This is a stable text quote for position restoration.</p><div role="heading" aria-level="3">ARIA heading</div></body></html>)HTML";
|
||||
html.replace("PORT", QByteArray::number(server_.serverPort()));
|
||||
write("index.html", html);
|
||||
write("vertical.html", "<!doctype html><html><meta charset=\"UTF-8\"><style>body{writing-mode:vertical-rl;height:500px}section{width:1200px}</style><section><h1>Vertical first</h1></section><section><h2>Vertical second</h2></section></html>");
|
||||
QByteArray reflow = "<!doctype html><html><meta charset=\"UTF-8\"><style>body{font-size:14px!important;line-height:1.3;margin:24px}p{max-width:42em;margin:1em 0}h1{font-size:24px}</style><h1>Logical position</h1>";
|
||||
for (int i = 0; i < 100; ++i) {
|
||||
reflow += "<p id='paragraph-" + QByteArray::number(i) + "'>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 += "</p>";
|
||||
}
|
||||
write("reflow.html", reflow + "</html>");
|
||||
QByteArray late = reflow;
|
||||
late.replace("<h1>Logical position</h1>", "<style>html{overflow-anchor:none}@font-face{font-family:Late;src:url(late-layout.ttf);font-display:swap}</style><h1>Logical position</h1><img id='late-image'>");
|
||||
write("late-layout.html", late + "</html>");
|
||||
write("late-vertical.html", late + "<style>body{writing-mode:vertical-rl;height:500px}</style></html>");
|
||||
write("late-image.svg", "<svg xmlns='http://www.w3.org/2000/svg' width='800' height='900'><rect width='800' height='900' fill='green'/></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", "<!doctype html><html><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=800,height=600\"><style>html,body{margin:0;width:800px;height:600px;overflow:hidden}body{font-size:19px}h1{font-size:24px}#frame{position:absolute;left:0;top:0;width:800px;height:600px;box-sizing:border-box;border:8px solid green}</style><div id=\"frame\"><h1>Fixed viewport</h1><p>Fixed text size</p></div></html>");
|
||||
write("rtl.html", "<!doctype html><html dir=\"rtl\"><meta charset=\"UTF-8\"><style>body{margin:24px}section{height:1200px}</style><section><h1>RTL first</h1><p>مرحبا بالعالم</p></section><section><h2>RTL second</h2><p>النص الثاني</p></section></html>");
|
||||
write("search.html", "<!doctype html><html><meta charset=\"UTF-8\"><style>p{margin:400px 0}</style><h1>Find text</h1><p>needle one</p><p>NEEDLE two</p><p>needle three</p><p>日本語 検索</p><p>日本語 検索</p></html>");
|
||||
for (const auto &name : {QStringLiteral("space name.html"), QStringLiteral("日本語.html"),
|
||||
QStringLiteral("hash#percent%.html"), QStringLiteral("日本語 j k 123 #%.html")})
|
||||
write(name, "<!doctype html><meta charset='utf-8'><meta http-equiv='refresh' content='1;url=next.html'>"
|
||||
"<h1>Encoded path</h1><p id='value'>Correct document</p><div style='height:1500px'></div><h2 id='target'>Target</h2>");
|
||||
}
|
||||
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"
|
||||
Reference in New Issue
Block a user