mirror of
https://github.com/Mudlet/Mudlet
synced 2026-08-13 18:26:27 -04:00
567 lines
28 KiB
C++
567 lines
28 KiB
C++
|
|
/***************************************************************************
|
||
|
|
* Copyright (C) 2026 by Vadim Peretokin - vadim.peretokin@mudlet.org *
|
||
|
|
* *
|
||
|
|
* This program is free software; you can redistribute it and/or modify *
|
||
|
|
* it under the terms of the GNU General Public License as published by *
|
||
|
|
* the Free Software Foundation; either version 2 of the License, or *
|
||
|
|
* (at your option) any later version. *
|
||
|
|
* *
|
||
|
|
* This program is distributed in the hope that it will be useful, *
|
||
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||
|
|
* GNU General Public License for more details. *
|
||
|
|
* *
|
||
|
|
* You should have received a copy of the GNU General Public License *
|
||
|
|
* along with this program; if not, write to the *
|
||
|
|
* Free Software Foundation, Inc., *
|
||
|
|
* 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. *
|
||
|
|
***************************************************************************/
|
||
|
|
|
||
|
|
/*
|
||
|
|
* Tests that an unterminated ANSI string sequence cannot silence the game -
|
||
|
|
* https://github.com/Mudlet/Mudlet/issues/9757
|
||
|
|
*
|
||
|
|
* ESC followed by 'P', 'X', '^', '_' or ']' starts a DCS, SOS, PM, APC or OSC
|
||
|
|
* sequence, whose payload is consumed rather than displayed. A game that emits
|
||
|
|
* one of those introducers by accident must not blacken the output that
|
||
|
|
* follows it: the sequence ends at the first BEL, ESC \ or line ending, and a
|
||
|
|
* connection boundary drops it outright. Well-formed sequences must still be
|
||
|
|
* consumed in full, including ones that are larger than the buffering cap or
|
||
|
|
* split over several packets.
|
||
|
|
*
|
||
|
|
* Everything is driven over a real TCP socket via TelnetServerStub so that the
|
||
|
|
* bytes travel the same path as a game's output.
|
||
|
|
*
|
||
|
|
* Run with: ctest -R TelnetStringSequenceRecoveryTest -V
|
||
|
|
*/
|
||
|
|
|
||
|
|
#include <QRegularExpression>
|
||
|
|
#include <QtTest/QtTest>
|
||
|
|
#include <chrono>
|
||
|
|
|
||
|
|
#include "Host.h"
|
||
|
|
#include "MudletInstanceCoordinator.h"
|
||
|
|
#include "TMainConsole.h"
|
||
|
|
#include "TelnetServerStub.h"
|
||
|
|
#include "ctelnet.h"
|
||
|
|
#include "dlgConnectionProfiles.h"
|
||
|
|
#include "mudlet.h"
|
||
|
|
|
||
|
|
using namespace std::chrono_literals;
|
||
|
|
|
||
|
|
extern void qInitResources_mudlet();
|
||
|
|
extern void qInitResources_qm();
|
||
|
|
extern void qInitResources_additional_splash_screens();
|
||
|
|
extern void qInitResources_mudlet_fonts_common();
|
||
|
|
extern void qInitResources_mudlet_fonts_posix();
|
||
|
|
void initializeQRCResourcesForStringSequenceRecoveryTest();
|
||
|
|
|
||
|
|
namespace {
|
||
|
|
const QByteArray csBel = QByteArrayLiteral("\x07");
|
||
|
|
const QByteArray csStringTerminator = QByteArrayLiteral("\x1b") + QByteArrayLiteral("\\");
|
||
|
|
const QByteArray csCrLf = QByteArrayLiteral("\r\n");
|
||
|
|
// Distinctive enough that finding it anywhere in the buffer means a payload
|
||
|
|
// that should have been consumed was displayed instead:
|
||
|
|
const QByteArray csPayload = QByteArrayLiteral("0;SEQPAYLOAD");
|
||
|
|
} // namespace
|
||
|
|
|
||
|
|
class TelnetStringSequenceRecoveryTest : public QObject
|
||
|
|
{
|
||
|
|
Q_OBJECT
|
||
|
|
|
||
|
|
private:
|
||
|
|
TelnetServerStub* mpServer = nullptr;
|
||
|
|
Host* mpHost = nullptr;
|
||
|
|
const QString mHostname = qsl("String-Sequence-Recovery-Test-Host");
|
||
|
|
QString mPort; // assigned the stub's actual ephemeral port in initTestCase()
|
||
|
|
const QString mLocalhost = qsl("localhost");
|
||
|
|
|
||
|
|
// Every introducer that starts a sequence whose payload is consumed
|
||
|
|
// instead of displayed.
|
||
|
|
static void addIntroducerColumn()
|
||
|
|
{
|
||
|
|
QTest::addColumn<QByteArray>("introducer");
|
||
|
|
QTest::newRow("DCS (ESC P)") << QByteArrayLiteral("\x1b") + QByteArrayLiteral("P");
|
||
|
|
QTest::newRow("SOS (ESC X)") << QByteArrayLiteral("\x1b") + QByteArrayLiteral("X");
|
||
|
|
QTest::newRow("PM (ESC ^)") << QByteArrayLiteral("\x1b") + QByteArrayLiteral("^");
|
||
|
|
QTest::newRow("APC (ESC _)") << QByteArrayLiteral("\x1b") + QByteArrayLiteral("_");
|
||
|
|
QTest::newRow("OSC (ESC ])") << QByteArrayLiteral("\x1b") + QByteArrayLiteral("]");
|
||
|
|
}
|
||
|
|
|
||
|
|
void sendFromServer(const QByteArray& data) { mpServer->sendRaw(data); }
|
||
|
|
|
||
|
|
// Sends data that does not end a line and waits out cTelnet's 300ms idle
|
||
|
|
// flush, so the parser has seen every byte of it - including the carriage
|
||
|
|
// return that flush puts through the parser - by the time this returns.
|
||
|
|
void sendFromServerAndSettle(const QByteArray& data)
|
||
|
|
{
|
||
|
|
mpServer->sendRaw(data);
|
||
|
|
QTest::qWait(500ms);
|
||
|
|
}
|
||
|
|
|
||
|
|
QString joinedBuffer() const
|
||
|
|
{
|
||
|
|
QStringList lines;
|
||
|
|
for (int i = 0; i <= mpHost->mpConsole->buffer.getLastLineNumber(); ++i) {
|
||
|
|
lines << mpHost->mpConsole->buffer.line(i);
|
||
|
|
}
|
||
|
|
return lines.join(QChar::LineFeed);
|
||
|
|
}
|
||
|
|
|
||
|
|
bool waitForBufferText(const QString& needle, int timeoutMs = 5000)
|
||
|
|
{
|
||
|
|
return QTest::qWaitFor(
|
||
|
|
[this, &needle]() {
|
||
|
|
return joinedBuffer().contains(needle);
|
||
|
|
},
|
||
|
|
timeoutMs);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Several times MAX_OSC_SEQUENCE_LENGTH, and position-dependent so that a
|
||
|
|
// failure says where the payload started leaking.
|
||
|
|
static QByteArray oversizedPayload()
|
||
|
|
{
|
||
|
|
QByteArray payload;
|
||
|
|
for (int i = 0; payload.size() < 20000; ++i) {
|
||
|
|
payload += QByteArrayLiteral("payload-") + QByteArray::number(i).rightJustified(4, '0') + QByteArrayLiteral(" ");
|
||
|
|
}
|
||
|
|
return payload;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Takes the profile offline and back on with the stub greeting the new
|
||
|
|
// connection with welcomeMessage, leaving an empty display behind so that
|
||
|
|
// greeting is the first thing in it.
|
||
|
|
bool reconnectWithWelcomeMessage(const QString& welcomeMessage)
|
||
|
|
{
|
||
|
|
mpServer->setWelcomeMessage(welcomeMessage);
|
||
|
|
mpHost->mTelnet.disconnectIt();
|
||
|
|
const bool wentOffline = QTest::qWaitFor(
|
||
|
|
[this]() {
|
||
|
|
return mpHost->mTelnet.getConnectionState() == QAbstractSocket::UnconnectedState;
|
||
|
|
},
|
||
|
|
5000);
|
||
|
|
mpHost->mpConsole->buffer.clear();
|
||
|
|
QSignalSpy connectedSpy(&(mpHost->mTelnet), &cTelnet::signal_connected);
|
||
|
|
mpHost->mTelnet.connectIt(mLocalhost, mPort.toInt());
|
||
|
|
const bool reconnected = connectedSpy.wait(5000);
|
||
|
|
const bool arrived = waitForBufferText(welcomeMessage);
|
||
|
|
mpServer->setWelcomeMessage(QString());
|
||
|
|
return wentOffline && reconnected && arrived;
|
||
|
|
}
|
||
|
|
|
||
|
|
bool bufferHasLine(const QString& text) const
|
||
|
|
{
|
||
|
|
for (int i = 0; i <= mpHost->mpConsole->buffer.getLastLineNumber(); ++i) {
|
||
|
|
if (mpHost->mpConsole->buffer.line(i) == text) {
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
private slots:
|
||
|
|
// Start mudlet and create a profile once for all tests.
|
||
|
|
void initTestCase()
|
||
|
|
{
|
||
|
|
initializeQRCResourcesForStringSequenceRecoveryTest();
|
||
|
|
|
||
|
|
mpServer = new TelnetServerStub(qApp);
|
||
|
|
mpServer->start(mLocalhost, 0); // ephemeral OS-assigned port avoids collisions across concurrent test runs
|
||
|
|
QVERIFY2(mpServer->isListening(), "TelnetServerStub failed to bind a loopback port");
|
||
|
|
mPort = QString::number(mpServer->serverPort());
|
||
|
|
mudlet::start();
|
||
|
|
mudlet::self()->setupConfig();
|
||
|
|
mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>("MudletInstanceCoordinator"));
|
||
|
|
mudlet::self()->init();
|
||
|
|
mudlet::self()->setStorePasswordsSecurely(false);
|
||
|
|
|
||
|
|
const QString path = mudlet::getMudletPath(enums::profileHomePath, mHostname);
|
||
|
|
QDir(path).removeRecursively();
|
||
|
|
|
||
|
|
QTimer::singleShot(0ms, qApp, [this]() {
|
||
|
|
mudlet::self()->startAutoLogin({});
|
||
|
|
QTest::qWait(100ms);
|
||
|
|
QTest::mouseClick(mudlet::self()->mpConnectionDialog->new_profile_button, Qt::LeftButton);
|
||
|
|
QTest::qWait(100ms);
|
||
|
|
QTest::keyClicks(QApplication::focusWidget(), mHostname);
|
||
|
|
QTest::qWait(100ms);
|
||
|
|
QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab);
|
||
|
|
QTest::qWait(100ms);
|
||
|
|
QTest::keyClicks(QApplication::focusWidget(), mLocalhost);
|
||
|
|
QTest::qWait(100ms);
|
||
|
|
QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab);
|
||
|
|
QTest::qWait(100ms);
|
||
|
|
QTest::keyClicks(QApplication::focusWidget(), mPort);
|
||
|
|
QTest::qWait(100ms);
|
||
|
|
QTest::keyClick(QApplication::focusWidget(), Qt::Key_Return);
|
||
|
|
});
|
||
|
|
|
||
|
|
QSignalSpy spy(mudlet::self(), &mudlet::signal_profileLoaded);
|
||
|
|
if (!spy.wait(5000)) {
|
||
|
|
QFAIL("Profile took too long to load.");
|
||
|
|
}
|
||
|
|
mpHost = mudlet::self()->getActiveHost();
|
||
|
|
if (!mpHost) {
|
||
|
|
QFAIL("No active host available for the test.");
|
||
|
|
}
|
||
|
|
|
||
|
|
QSignalSpy spy2(&(mpHost->mTelnet), &cTelnet::signal_connected);
|
||
|
|
if (!spy2.wait(2000)) {
|
||
|
|
QFAIL("Could not connect with the host.");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Each test starts from an empty display and a parser that is not part way
|
||
|
|
// through a sequence, so a failure cannot cascade into the tests after it.
|
||
|
|
void init()
|
||
|
|
{
|
||
|
|
QVERIFY(mpHost);
|
||
|
|
QVERIFY(mpHost->mpConsole);
|
||
|
|
mpHost->mpConsole->buffer.clear();
|
||
|
|
mpHost->mpConsole->buffer.resetSequenceParserState();
|
||
|
|
}
|
||
|
|
|
||
|
|
// A BEL ends the sequence: the payload is consumed and the text around it
|
||
|
|
// is displayed.
|
||
|
|
void recoveryByBel_data() { addIntroducerColumn(); }
|
||
|
|
|
||
|
|
void recoveryByBel()
|
||
|
|
{
|
||
|
|
QFETCH(QByteArray, introducer);
|
||
|
|
|
||
|
|
sendFromServer(QByteArrayLiteral("PRE") + introducer + csPayload + csBel + QByteArrayLiteral("POST") + csCrLf);
|
||
|
|
|
||
|
|
QVERIFY2(waitForBufferText(qsl("PREPOST")), qPrintable(qsl("Text around the sequence went missing, buffer holds: '%1'").arg(joinedBuffer())));
|
||
|
|
QVERIFY2(!joinedBuffer().contains(qsl("SEQPAYLOAD")), qPrintable(qsl("Sequence payload was displayed: '%1'").arg(joinedBuffer())));
|
||
|
|
}
|
||
|
|
|
||
|
|
// ESC \ (the String Terminator) ends the sequence just as a BEL does.
|
||
|
|
void recoveryByStringTerminator_data() { addIntroducerColumn(); }
|
||
|
|
|
||
|
|
void recoveryByStringTerminator()
|
||
|
|
{
|
||
|
|
QFETCH(QByteArray, introducer);
|
||
|
|
|
||
|
|
sendFromServer(QByteArrayLiteral("PRE") + introducer + csPayload + csStringTerminator + QByteArrayLiteral("POST") + csCrLf);
|
||
|
|
|
||
|
|
QVERIFY2(waitForBufferText(qsl("PREPOST")), qPrintable(qsl("Text around the sequence went missing, buffer holds: '%1'").arg(joinedBuffer())));
|
||
|
|
QVERIFY2(!joinedBuffer().contains(qsl("SEQPAYLOAD")), qPrintable(qsl("Sequence payload was displayed: '%1'").arg(joinedBuffer())));
|
||
|
|
}
|
||
|
|
|
||
|
|
// The reported bug: with no terminator at all the sequence must end with
|
||
|
|
// the line it started on, so the lines after it are still displayed.
|
||
|
|
void recoveryByEndOfLine_data() { addIntroducerColumn(); }
|
||
|
|
|
||
|
|
void recoveryByEndOfLine()
|
||
|
|
{
|
||
|
|
QFETCH(QByteArray, introducer);
|
||
|
|
|
||
|
|
sendFromServer(QByteArrayLiteral("PRE") + introducer + csPayload + csCrLf + QByteArrayLiteral("RESUMED-ONE") + csCrLf + QByteArrayLiteral("RESUMED-TWO") + csCrLf
|
||
|
|
+ QByteArrayLiteral("RESUMED-THREE") + csCrLf);
|
||
|
|
|
||
|
|
QVERIFY2(waitForBufferText(qsl("RESUMED-THREE")), qPrintable(qsl("Output stayed dark after an unterminated sequence, buffer holds: '%1'").arg(joinedBuffer())));
|
||
|
|
const QString displayed = joinedBuffer();
|
||
|
|
QVERIFY2(displayed.contains(qsl("RESUMED-ONE")), qPrintable(qsl("First line after the sequence went missing: '%1'").arg(displayed)));
|
||
|
|
QVERIFY2(displayed.contains(qsl("RESUMED-TWO")), qPrintable(qsl("Second line after the sequence went missing: '%1'").arg(displayed)));
|
||
|
|
QVERIFY2(bufferHasLine(qsl("PRE")), qPrintable(qsl("Text before the introducer went missing: '%1'").arg(displayed)));
|
||
|
|
QVERIFY2(!displayed.contains(qsl("SEQPAYLOAD")), qPrintable(qsl("Sequence payload was displayed: '%1'").arg(displayed)));
|
||
|
|
}
|
||
|
|
|
||
|
|
// A game that keeps talking after an unterminated sequence has every line
|
||
|
|
// of it displayed, not just the first.
|
||
|
|
void volumeAfterUnterminatedSequence_data() { addIntroducerColumn(); }
|
||
|
|
|
||
|
|
void volumeAfterUnterminatedSequence()
|
||
|
|
{
|
||
|
|
QFETCH(QByteArray, introducer);
|
||
|
|
|
||
|
|
QByteArray data = QByteArrayLiteral("The chest is locked.") + introducer + csPayload + csCrLf;
|
||
|
|
for (int i = 0; i < 50; ++i) {
|
||
|
|
data += QByteArrayLiteral("bulk line ") + QByteArray::number(i) + csCrLf;
|
||
|
|
}
|
||
|
|
sendFromServer(data);
|
||
|
|
|
||
|
|
QVERIFY2(waitForBufferText(qsl("bulk line 49")), qPrintable(qsl("Bulk output after an unterminated sequence was lost, buffer holds: '%1'").arg(joinedBuffer())));
|
||
|
|
for (int i = 0; i < 50; ++i) {
|
||
|
|
QVERIFY2(bufferHasLine(qsl("bulk line %1").arg(i)), qPrintable(qsl("Line %1 was lost, buffer holds: '%2'").arg(QString::number(i), joinedBuffer())));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// A sequence still waiting for its terminator when the display is cleared
|
||
|
|
// must not blacken the output that follows the clear.
|
||
|
|
void recoveryAfterClear_data() { addIntroducerColumn(); }
|
||
|
|
|
||
|
|
void recoveryAfterClear()
|
||
|
|
{
|
||
|
|
QFETCH(QByteArray, introducer);
|
||
|
|
|
||
|
|
// No line ending, so the parser is legitimately still inside the
|
||
|
|
// sequence when the display is cleared:
|
||
|
|
sendFromServerAndSettle(QByteArrayLiteral("BEFORE-CLEAR") + introducer + csPayload);
|
||
|
|
mpHost->mpConsole->buffer.clear();
|
||
|
|
|
||
|
|
// clear() deliberately leaves the parser alone, so the sequence runs
|
||
|
|
// on to the next line ending the game sends - which costs the first of
|
||
|
|
// these lines, unless the game happened to end a line in between:
|
||
|
|
sendFromServer(QByteArrayLiteral("AFTER-CLEAR-ONE") + csCrLf + QByteArrayLiteral("AFTER-CLEAR-TWO") + csCrLf);
|
||
|
|
|
||
|
|
QVERIFY2(waitForBufferText(qsl("AFTER-CLEAR-TWO")), qPrintable(qsl("Output stayed dark after clear(), buffer holds: '%1'").arg(joinedBuffer())));
|
||
|
|
}
|
||
|
|
|
||
|
|
// Bytes that a dead connection never terminated cannot be completed by the
|
||
|
|
// next one, so the very first line of a new session is displayed.
|
||
|
|
void recoveryAfterReconnect_data() { addIntroducerColumn(); }
|
||
|
|
|
||
|
|
void recoveryAfterReconnect()
|
||
|
|
{
|
||
|
|
QFETCH(QByteArray, introducer);
|
||
|
|
|
||
|
|
sendFromServerAndSettle(QByteArrayLiteral("BEFORE-RECONNECT") + introducer + csPayload);
|
||
|
|
|
||
|
|
QVERIFY2(reconnectWithWelcomeMessage(qsl("NEW-SESSION-FIRST-LINE")),
|
||
|
|
qPrintable(qsl("The new connection's first line was eaten by the old one's sequence, buffer holds: '%1'").arg(joinedBuffer())));
|
||
|
|
}
|
||
|
|
|
||
|
|
// The other parser latches are dropped at the same boundary: half of a CSI
|
||
|
|
// would otherwise swallow the head of the new session's first line.
|
||
|
|
void halfReceivedCsiDoesNotSurviveReconnect()
|
||
|
|
{
|
||
|
|
sendFromServerAndSettle(QByteArrayLiteral("BEFORE-RECONNECT") + QByteArrayLiteral("\x1b") + QByteArrayLiteral("[3"));
|
||
|
|
|
||
|
|
// A carried-over "CSI 3" would swallow the 'C' as its final byte:
|
||
|
|
QVERIFY2(reconnectWithWelcomeMessage(qsl("CSI-RESET-CHECK")), qPrintable(qsl("A half-received CSI ate the head of the new session's first line, buffer holds: '%1'").arg(joinedBuffer())));
|
||
|
|
}
|
||
|
|
|
||
|
|
// A Sixel image is a well-formed DCS: its payload must be consumed in full
|
||
|
|
// rather than spilled onto the screen.
|
||
|
|
void wellFormedSixelIsConsumed()
|
||
|
|
{
|
||
|
|
sendFromServer(QByteArrayLiteral("BEFORE-SIXEL") + QByteArrayLiteral("\x1b") + QByteArrayLiteral("P0;1;0q\"1;1;6;6#0;2;0;0;0#0!6~-") + csStringTerminator + QByteArrayLiteral("AFTER-SIXEL")
|
||
|
|
+ csCrLf);
|
||
|
|
|
||
|
|
QVERIFY2(waitForBufferText(qsl("BEFORE-SIXELAFTER-SIXEL")), qPrintable(qsl("Text around the Sixel image went missing, buffer holds: '%1'").arg(joinedBuffer())));
|
||
|
|
QVERIFY2(!joinedBuffer().contains(qsl("#0;2;0;0;0")), qPrintable(qsl("Sixel payload was displayed: '%1'").arg(joinedBuffer())));
|
||
|
|
}
|
||
|
|
|
||
|
|
// The Kitty graphics protocol's APC payloads are consumed the same way.
|
||
|
|
void wellFormedKittyGraphicIsConsumed()
|
||
|
|
{
|
||
|
|
sendFromServer(QByteArrayLiteral("BEFORE-KITTY") + QByteArrayLiteral("\x1b") + QByteArrayLiteral("_Ga=T,f=100,s=1,v=1;iVBORw0KGgoAAAANSUhEUg==") + csStringTerminator
|
||
|
|
+ QByteArrayLiteral("AFTER-KITTY") + csCrLf);
|
||
|
|
|
||
|
|
QVERIFY2(waitForBufferText(qsl("BEFORE-KITTYAFTER-KITTY")), qPrintable(qsl("Text around the Kitty graphic went missing, buffer holds: '%1'").arg(joinedBuffer())));
|
||
|
|
QVERIFY2(!joinedBuffer().contains(qsl("iVBORw0KGgo")), qPrintable(qsl("Kitty payload was displayed: '%1'").arg(joinedBuffer())));
|
||
|
|
}
|
||
|
|
|
||
|
|
// Real images run to far more than the buffering cap, so a well-formed
|
||
|
|
// sequence that is longer than it still has to be consumed in full.
|
||
|
|
void wellFormedSequenceLargerThanTheCapIsConsumed()
|
||
|
|
{
|
||
|
|
sendFromServer(QByteArrayLiteral("BEFORE-HUGE") + QByteArrayLiteral("\x1b") + QByteArrayLiteral("P") + oversizedPayload() + csStringTerminator + QByteArrayLiteral("AFTER-HUGE") + csCrLf);
|
||
|
|
|
||
|
|
QVERIFY2(waitForBufferText(qsl("AFTER-HUGE")), qPrintable(qsl("Text after an oversized sequence went missing, buffer holds: '%1'").arg(joinedBuffer())));
|
||
|
|
QVERIFY2(joinedBuffer().contains(qsl("BEFORE-HUGE")), qPrintable(qsl("Text before an oversized sequence went missing, buffer holds: '%1'").arg(joinedBuffer())));
|
||
|
|
QVERIFY2(!joinedBuffer().contains(qsl("payload-0000")), qPrintable(qsl("Payload of the oversized sequence was displayed: '%1'").arg(joinedBuffer())));
|
||
|
|
QVERIFY2(!joinedBuffer().contains(qsl("payload-1000")), qPrintable(qsl("Payload of the oversized sequence was displayed past the cap: '%1'").arg(joinedBuffer())));
|
||
|
|
}
|
||
|
|
|
||
|
|
// An OSC is the one string sequence whose payload Mudlet acts on, so one
|
||
|
|
// that outgrows the cap loses its command - which has to be reported, not
|
||
|
|
// swallowed, and must not take the rest of the line with it.
|
||
|
|
void oversizedOscIsReportedAndItsCommandIgnored()
|
||
|
|
{
|
||
|
|
QTest::ignoreMessage(QtWarningMsg, QRegularExpression(qsl("an OSC sequence passed 4096 bytes, so its command will be ignored")));
|
||
|
|
|
||
|
|
sendFromServer(QByteArrayLiteral("\x1b") + QByteArrayLiteral("]") + oversizedPayload() + csBel + QByteArrayLiteral("AFTER-OVERSIZED-OSC") + csCrLf);
|
||
|
|
|
||
|
|
QVERIFY2(waitForBufferText(qsl("AFTER-OVERSIZED-OSC")), qPrintable(qsl("Text after an oversized OSC went missing, buffer holds: '%1'").arg(joinedBuffer())));
|
||
|
|
QVERIFY2(!joinedBuffer().contains(qsl("payload-1000")), qPrintable(qsl("Payload of the oversized OSC was displayed: '%1'").arg(joinedBuffer())));
|
||
|
|
}
|
||
|
|
|
||
|
|
// Giving up on one sequence must not leave the parser refusing to decode
|
||
|
|
// every OSC that follows it.
|
||
|
|
void oscStillDecodesAfterAnUnterminatedSequence()
|
||
|
|
{
|
||
|
|
const bool savedMayRedefine = mpHost->getMayRedefineColors();
|
||
|
|
const QColor savedRed = mpHost->mRed;
|
||
|
|
mpHost->setMayRedefineColors(true);
|
||
|
|
|
||
|
|
sendFromServer(QByteArrayLiteral("BEFORE-ABORT") + QByteArrayLiteral("\x1b") + QByteArrayLiteral("]") + csPayload + csCrLf);
|
||
|
|
const bool aborted = waitForBufferText(qsl("BEFORE-ABORT"));
|
||
|
|
sendFromServer(QByteArrayLiteral("\x1b") + QByteArrayLiteral("]P1223344") + csBel + QByteArrayLiteral("OSC-AFTER-ABORT") + csCrLf);
|
||
|
|
const bool arrived = waitForBufferText(qsl("OSC-AFTER-ABORT"));
|
||
|
|
const QColor redAfter = mpHost->mRed;
|
||
|
|
|
||
|
|
mpHost->mRed = savedRed;
|
||
|
|
mpHost->setMayRedefineColors(savedMayRedefine);
|
||
|
|
|
||
|
|
QVERIFY2(aborted, "The line carrying the unterminated sequence never arrived");
|
||
|
|
QVERIFY2(arrived, qPrintable(qsl("Text after the second OSC went missing, buffer holds: '%1'").arg(joinedBuffer())));
|
||
|
|
QVERIFY2(redAfter == QColor(0x22, 0x33, 0x44), qPrintable(qsl("An OSC sent after an abandoned sequence was consumed but not decoded, red is %1").arg(redAfter.name())));
|
||
|
|
}
|
||
|
|
|
||
|
|
// Bounding the sequence at the end of a line must not break a well-formed
|
||
|
|
// one that arrives in several packets - including one that pauses for
|
||
|
|
// longer than cTelnet's 300ms idle flush, which puts a carriage return
|
||
|
|
// through the parser part way through the payload.
|
||
|
|
void wellFormedSequenceSplitAcrossReads()
|
||
|
|
{
|
||
|
|
sendFromServerAndSettle(QByteArrayLiteral("PRE-SPLIT") + QByteArrayLiteral("\x1b") + QByteArrayLiteral("P") + QByteArrayLiteral("SEQ"));
|
||
|
|
|
||
|
|
sendFromServer(QByteArrayLiteral("PAYLOAD") + csStringTerminator + QByteArrayLiteral("POST-SPLIT") + csCrLf);
|
||
|
|
|
||
|
|
QVERIFY2(waitForBufferText(qsl("PRE-SPLITPOST-SPLIT")), qPrintable(qsl("A sequence split across two reads lost the text around it: '%1'").arg(joinedBuffer())));
|
||
|
|
QVERIFY2(!joinedBuffer().contains(qsl("SEQ")), qPrintable(qsl("Payload of the split sequence was displayed: '%1'").arg(joinedBuffer())));
|
||
|
|
}
|
||
|
|
|
||
|
|
// ...and an unterminated one that is split still ends at the line ending
|
||
|
|
// that arrives in the later packet.
|
||
|
|
void unterminatedSequenceSplitAcrossReads()
|
||
|
|
{
|
||
|
|
sendFromServerAndSettle(QByteArrayLiteral("PRE-SPLIT-BOUND") + QByteArrayLiteral("\x1b") + QByteArrayLiteral("X") + csPayload);
|
||
|
|
|
||
|
|
sendFromServer(csCrLf + QByteArrayLiteral("RESUMED-AFTER-SPLIT") + csCrLf);
|
||
|
|
|
||
|
|
QVERIFY2(waitForBufferText(qsl("RESUMED-AFTER-SPLIT")), qPrintable(qsl("Output stayed dark after a split unterminated sequence: '%1'").arg(joinedBuffer())));
|
||
|
|
}
|
||
|
|
|
||
|
|
// The worst place for the idle flush's carriage return to land is between
|
||
|
|
// the ESC and the backslash of a String Terminator: taken as payload it
|
||
|
|
// would hide the terminator and cost the rest of that line.
|
||
|
|
void stringTerminatorSplitByTheIdleFlush()
|
||
|
|
{
|
||
|
|
sendFromServerAndSettle(QByteArrayLiteral("PRE-ST-SPLIT") + QByteArrayLiteral("\x1b") + QByteArrayLiteral("P") + QByteArrayLiteral("PAYLOAD") + QByteArrayLiteral("\x1b"));
|
||
|
|
|
||
|
|
sendFromServer(QByteArrayLiteral("\\") + QByteArrayLiteral("AFTER-ST-SPLIT") + csCrLf);
|
||
|
|
|
||
|
|
QVERIFY2(waitForBufferText(qsl("AFTER-ST-SPLIT")), qPrintable(qsl("The String Terminator was missed and the rest of the line was discarded: '%1'").arg(joinedBuffer())));
|
||
|
|
QVERIFY2(!joinedBuffer().contains(qsl("PAYLOAD")), qPrintable(qsl("Payload of the split sequence was displayed: '%1'").arg(joinedBuffer())));
|
||
|
|
}
|
||
|
|
|
||
|
|
// The line endings that are not a line feed: a prompt marker (the game's
|
||
|
|
// IAC GA, or an escaped 0xff byte) and an end of transmission.
|
||
|
|
void recoveryByOtherLineEndings_data()
|
||
|
|
{
|
||
|
|
QTest::addColumn<QByteArray>("lineEnding");
|
||
|
|
QTest::newRow("end of transmission (0x04)") << QByteArrayLiteral("\x04");
|
||
|
|
// Doubled so that the telnet layer delivers one literal 0xff byte
|
||
|
|
// rather than reading it as the start of a command:
|
||
|
|
QTest::newRow("prompt marker (0xff)") << QByteArrayLiteral("\xff\xff");
|
||
|
|
}
|
||
|
|
|
||
|
|
void recoveryByOtherLineEndings()
|
||
|
|
{
|
||
|
|
QFETCH(QByteArray, lineEnding);
|
||
|
|
|
||
|
|
sendFromServer(QByteArrayLiteral("\x1b") + QByteArrayLiteral("P") + csPayload + lineEnding + QByteArrayLiteral("RESUMED-AFTER-ENDING") + csCrLf);
|
||
|
|
|
||
|
|
QVERIFY2(waitForBufferText(qsl("RESUMED-AFTER-ENDING")), qPrintable(qsl("Output stayed dark after the line ending, buffer holds: '%1'").arg(joinedBuffer())));
|
||
|
|
QVERIFY2(!joinedBuffer().contains(qsl("SEQPAYLOAD")), qPrintable(qsl("Sequence payload was displayed: '%1'").arg(joinedBuffer())));
|
||
|
|
}
|
||
|
|
|
||
|
|
// Locally generated text (feedTriggers(), MMCP chat, MXP insertions) runs
|
||
|
|
// through the same parser but keeps its own copy of the latches, so it
|
||
|
|
// needs the same bound.
|
||
|
|
void unterminatedSequenceInALocalFeedIsBounded()
|
||
|
|
{
|
||
|
|
std::string stuck{"BEFORE-LOCAL\x1bP0;SEQPAYLOAD"};
|
||
|
|
mpHost->mpConsole->printOnDisplay(stuck, false);
|
||
|
|
// As on the Game Server channel, the line ending of this one is what
|
||
|
|
// ends the sequence, so this feed is the one that is lost:
|
||
|
|
std::string eaten{"EATEN-LOCAL\n"};
|
||
|
|
mpHost->mpConsole->printOnDisplay(eaten, false);
|
||
|
|
std::string resumed{"VISIBLE-LOCAL\n"};
|
||
|
|
mpHost->mpConsole->printOnDisplay(resumed, false);
|
||
|
|
|
||
|
|
QVERIFY2(waitForBufferText(qsl("VISIBLE-LOCAL")), qPrintable(qsl("Locally fed text stayed dark after an unterminated sequence: '%1'").arg(joinedBuffer())));
|
||
|
|
QVERIFY2(!joinedBuffer().contains(qsl("SEQPAYLOAD")), qPrintable(qsl("Sequence payload was displayed: '%1'").arg(joinedBuffer())));
|
||
|
|
}
|
||
|
|
|
||
|
|
// Bounding the sequence must not stop a complete OSC from being acted on.
|
||
|
|
void wellFormedOscIsStillDecoded()
|
||
|
|
{
|
||
|
|
const bool savedMayRedefine = mpHost->getMayRedefineColors();
|
||
|
|
const QColor savedRed = mpHost->mRed;
|
||
|
|
mpHost->setMayRedefineColors(true);
|
||
|
|
|
||
|
|
sendFromServer(QByteArrayLiteral("\x1b") + QByteArrayLiteral("]P1223344") + csBel + QByteArrayLiteral("OSC-DECODED") + csCrLf);
|
||
|
|
const bool arrived = waitForBufferText(qsl("OSC-DECODED"));
|
||
|
|
const QColor redAfter = mpHost->mRed;
|
||
|
|
|
||
|
|
mpHost->mRed = savedRed;
|
||
|
|
mpHost->setMayRedefineColors(savedMayRedefine);
|
||
|
|
|
||
|
|
QVERIFY2(arrived, qPrintable(qsl("Text after the OSC went missing, buffer holds: '%1'").arg(joinedBuffer())));
|
||
|
|
QVERIFY2(redAfter == QColor(0x22, 0x33, 0x44), qPrintable(qsl("The OSC colour redefinition was not decoded, red is %1").arg(redAfter.name())));
|
||
|
|
}
|
||
|
|
|
||
|
|
// The open question from the bug report: text the parser discards never
|
||
|
|
// reaches the buffer, so it is missing from the log as well - but the
|
||
|
|
// output that resumes after the sequence is logged as usual.
|
||
|
|
void logFollowsTheDisplayThroughAnUnterminatedSequence()
|
||
|
|
{
|
||
|
|
const QString savedLogDir = mpHost->mLogDir;
|
||
|
|
const QString savedLogFileName = mpHost->mLogFileName;
|
||
|
|
const QString savedLogFileNameFormat = mpHost->mLogFileNameFormat;
|
||
|
|
const bool savedHtmlFormat = mpHost->mIsNextLogFileInHtmlFormat;
|
||
|
|
auto restoreLoggingSettings = qScopeGuard([&]() {
|
||
|
|
mpHost->mLogDir = savedLogDir;
|
||
|
|
mpHost->mLogFileName = savedLogFileName;
|
||
|
|
mpHost->mLogFileNameFormat = savedLogFileNameFormat;
|
||
|
|
mpHost->mIsNextLogFileInHtmlFormat = savedHtmlFormat;
|
||
|
|
});
|
||
|
|
|
||
|
|
mpHost->mLogDir.clear();
|
||
|
|
mpHost->mLogFileNameFormat.clear();
|
||
|
|
mpHost->mLogFileName = qsl("string-sequence-recovery-test");
|
||
|
|
mpHost->mIsNextLogFileInHtmlFormat = false;
|
||
|
|
mpHost->mpConsole->toggleLogging(false);
|
||
|
|
QVERIFY(mpHost->mpConsole->mLogToLogFile);
|
||
|
|
const QString logFileName = mpHost->mpConsole->mLogFileName;
|
||
|
|
|
||
|
|
sendFromServer(QByteArrayLiteral("PRE-LOG") + QByteArrayLiteral("\x1b") + QByteArrayLiteral("P") + csPayload + csCrLf + QByteArrayLiteral("LOGGED-AFTER-BLACKOUT") + csCrLf
|
||
|
|
+ QByteArrayLiteral("LOGGED-LAST-LINE") + csCrLf);
|
||
|
|
QVERIFY2(waitForBufferText(qsl("LOGGED-LAST-LINE")), qPrintable(qsl("Output stayed dark, buffer holds: '%1'").arg(joinedBuffer())));
|
||
|
|
|
||
|
|
mpHost->mpConsole->toggleLogging(false);
|
||
|
|
QFile logFile(logFileName);
|
||
|
|
QVERIFY2(logFile.open(QIODevice::ReadOnly | QIODevice::Text), qPrintable(qsl("Could not read back the log at %1").arg(logFileName)));
|
||
|
|
const QString log = QString::fromUtf8(logFile.readAll());
|
||
|
|
logFile.close();
|
||
|
|
logFile.remove();
|
||
|
|
|
||
|
|
QVERIFY2(log.contains(qsl("PRE-LOG")), qPrintable(qsl("Text before the introducer is missing from the log: '%1'").arg(log)));
|
||
|
|
QVERIFY2(log.contains(qsl("LOGGED-AFTER-BLACKOUT")), qPrintable(qsl("The line after the unterminated sequence is missing from the log: '%1'").arg(log)));
|
||
|
|
QVERIFY2(log.contains(qsl("LOGGED-LAST-LINE")), qPrintable(qsl("The last line is missing from the log: '%1'").arg(log)));
|
||
|
|
QVERIFY2(!log.contains(qsl("SEQPAYLOAD")), qPrintable(qsl("Sequence payload reached the log: '%1'").arg(log)));
|
||
|
|
}
|
||
|
|
|
||
|
|
void cleanupTestCase()
|
||
|
|
{
|
||
|
|
delete mpServer;
|
||
|
|
mpServer = nullptr;
|
||
|
|
mpHost = nullptr;
|
||
|
|
const QString path = mudlet::getMudletPath(enums::profileHomePath, mHostname);
|
||
|
|
QDir(path).removeRecursively();
|
||
|
|
delete mudlet::self();
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
void initializeQRCResourcesForStringSequenceRecoveryTest()
|
||
|
|
{
|
||
|
|
#ifdef INCLUDE_VARIABLE_SPLASH_SCREEN
|
||
|
|
qInitResources_additional_splash_screens();
|
||
|
|
#endif
|
||
|
|
#ifdef INCLUDE_FONTS
|
||
|
|
qInitResources_mudlet_fonts_common();
|
||
|
|
#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD)
|
||
|
|
qInitResources_mudlet_fonts_posix();
|
||
|
|
#endif
|
||
|
|
#endif
|
||
|
|
qInitResources_mudlet();
|
||
|
|
qInitResources_qm();
|
||
|
|
}
|
||
|
|
|
||
|
|
#include "TelnetStringSequenceRecoveryTest.moc"
|
||
|
|
QTEST_MAIN(TelnetStringSequenceRecoveryTest)
|