mudlet/test/functional_tests/ActionSelfRemovalTest.cpp

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

264 lines
11 KiB
C++
Raw Permalink Normal View History

Fix: crash when a button uninstalls its own package (#9558) #### Brief overview of PR changes/additions A toolbar or menu button whose Lua script calls `uninstallPackage()` on its own package used to crash Mudlet. `ActionUnit::uninstall()` deleted the package's buttons immediately, but the clicked button is still inside `TAction::execute()`, which reads `this->mpHost` (to restore command-line focus) *after* the script returns - a heap-use-after-free. This applies the same processing-depth deferral already used for triggers/aliases/keys and timers/scripts: while a button's script is running, `uninstall()` deactivates and queues that package's actions instead of deleting them, and the delete happens later in `doCleanup()` once `execute()` has unwound. `ActionUnit` was the last object unit still deleting immediately. The queued deletes are flushed by the button dispatchers once the click returns and by the existing per-line / periodic `doCleanup()` calls in `Host`, and a seen-set guards against a double free from a re-entrant uninstall. #### Motivation for adding to Mudlet It is a hard crash reachable with a completely normal setup - a "reload/update this package" button that reinstalls itself is a common pattern. #### Other info (issues closed, discussion etc) Completes the self-uninstall use-after-free hardening across all object units - a follow-up to #9557 (timers/scripts) and #9383 (triggers/aliases/keys); actions were the one remaining unguarded unit. The crash is reported in Sentry as MUDLET-32 / MUDLET-2S / MUDLET-48. **Test case:** `test/functional_tests/ActionSelfUninstallTest.cpp` (Qt Test, built with AddressSanitizer) builds a synthetic package whose toolbar button uninstalls its own package, gives it a real toolbar widget, invokes the button, and asserts no crash, the package removed, and the deferred action freed cleanly with no double free. It trips a heap-use-after-free in `TAction::execute()` on the pre-fix code and is ASan-clean with the fix. To reproduce by hand: 1. Create a package containing one button whose script is `uninstallPackage("<that package's name>")`. 2. Click the button. 3. Before this PR: Mudlet crashes. After: the package uninstalls cleanly and focus returns to the command line. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Vadim Peretokin <vadim.peretokin@mudlet.org>
2026-07-30 12:02:32 +02:00
/***************************************************************************
* 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. *
***************************************************************************/
#include <QtTest/QtTest>
#include <QPushButton>
#include "ActionUnit.h"
#include "Host.h"
#include "MudletInstanceCoordinator.h"
#include "TAction.h"
#include "TFlipButton.h"
#include "TLuaInterpreter.h"
#include "TMainConsole.h"
#include "TelnetServerStub.h"
#include "ctelnet.h"
#include "dlgConnectionProfiles.h"
#include "mudlet.h"
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 initializeQRCResources();
// Regression tests for the self-uninstall use-after-free: a package toolbar
// button whose Lua script calls uninstallPackage() on its own package used to
// free the very TAction that TAction::execute() was running on, which then read
// this->mpHost after the Lua call returned (heap-use-after-free). ActionUnit now
// defers that delete until execute() has unwound.
infrastructure: rename the six tests Windows refuses to launch without elevation (#9753) #### Brief overview of PR changes/additions - Renames the six test executables whose filenames trip Windows' UAC installer detection: `UpdaterChecksumTest` to `ReleaseChecksumPairingTest`, `UpdaterPlatformAssetTest` to `ReleasePlatformAssetTest`, `UpdaterTeardownTest` to `NewReleaseDialogTeardownTest`, `PackageSelfUninstallTest` to `PackageSelfRemovalTest`, `PackageUninstallSaveTeardownTest` to `PackageRemovalSaveTeardownTest`, `ActionSelfUninstallTest` to `ActionSelfRemovalTest`. Assertions are untouched. - Adds a configure-time gate in `test/CMakeLists.txt` that fails with an actionable message if any test executable name contains install, setup, update or patch. It checks both the targets a configuration builds and the test source filenames, so conditionally registered tests cannot slip past it. - Documents the naming rule in `test/README.md`. #### Motivation for adding to Mudlet Windows treats an unsigned executable named that way as an installer and refuses to start it, so those six tests reported `BAD_COMMAND` for anyone running the suite from an ordinary Windows shell - and because CI runners are elevated, nothing caught it as more tests were added. #### Other info (issues closed, discussion etc) Fixes #9748 The gate was verified to fire on a target named after the guard statement, on one in a subdirectory, on `EventDispatcherTest` (the message names the offending substring, since "dispatch" contains "patch"), on a test registered only under `USE_UPDATER` when configuring with the updater off, and to fail loudly if the walk ever stops finding executables. **Test case:** `cmake --build build && ctest --test-dir build` - 92/92 pass; adding a test named e.g. `FooUpdateTest` fails the configure with an explanation. Assisted-by: Claude:claude-opus-5
2026-08-11 08:07:10 +02:00
class ActionSelfRemovalTest : public QObject
Fix: crash when a button uninstalls its own package (#9558) #### Brief overview of PR changes/additions A toolbar or menu button whose Lua script calls `uninstallPackage()` on its own package used to crash Mudlet. `ActionUnit::uninstall()` deleted the package's buttons immediately, but the clicked button is still inside `TAction::execute()`, which reads `this->mpHost` (to restore command-line focus) *after* the script returns - a heap-use-after-free. This applies the same processing-depth deferral already used for triggers/aliases/keys and timers/scripts: while a button's script is running, `uninstall()` deactivates and queues that package's actions instead of deleting them, and the delete happens later in `doCleanup()` once `execute()` has unwound. `ActionUnit` was the last object unit still deleting immediately. The queued deletes are flushed by the button dispatchers once the click returns and by the existing per-line / periodic `doCleanup()` calls in `Host`, and a seen-set guards against a double free from a re-entrant uninstall. #### Motivation for adding to Mudlet It is a hard crash reachable with a completely normal setup - a "reload/update this package" button that reinstalls itself is a common pattern. #### Other info (issues closed, discussion etc) Completes the self-uninstall use-after-free hardening across all object units - a follow-up to #9557 (timers/scripts) and #9383 (triggers/aliases/keys); actions were the one remaining unguarded unit. The crash is reported in Sentry as MUDLET-32 / MUDLET-2S / MUDLET-48. **Test case:** `test/functional_tests/ActionSelfUninstallTest.cpp` (Qt Test, built with AddressSanitizer) builds a synthetic package whose toolbar button uninstalls its own package, gives it a real toolbar widget, invokes the button, and asserts no crash, the package removed, and the deferred action freed cleanly with no double free. It trips a heap-use-after-free in `TAction::execute()` on the pre-fix code and is ASan-clean with the fix. To reproduce by hand: 1. Create a package containing one button whose script is `uninstallPackage("<that package's name>")`. 2. Click the button. 3. Before this PR: Mudlet crashes. After: the package uninstalls cleanly and focus returns to the command line. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Vadim Peretokin <vadim.peretokin@mudlet.org>
2026-07-30 12:02:32 +02:00
{
Q_OBJECT
private:
TelnetServerStub* mpServer = nullptr;
infrastructure: rename the six tests Windows refuses to launch without elevation (#9753) #### Brief overview of PR changes/additions - Renames the six test executables whose filenames trip Windows' UAC installer detection: `UpdaterChecksumTest` to `ReleaseChecksumPairingTest`, `UpdaterPlatformAssetTest` to `ReleasePlatformAssetTest`, `UpdaterTeardownTest` to `NewReleaseDialogTeardownTest`, `PackageSelfUninstallTest` to `PackageSelfRemovalTest`, `PackageUninstallSaveTeardownTest` to `PackageRemovalSaveTeardownTest`, `ActionSelfUninstallTest` to `ActionSelfRemovalTest`. Assertions are untouched. - Adds a configure-time gate in `test/CMakeLists.txt` that fails with an actionable message if any test executable name contains install, setup, update or patch. It checks both the targets a configuration builds and the test source filenames, so conditionally registered tests cannot slip past it. - Documents the naming rule in `test/README.md`. #### Motivation for adding to Mudlet Windows treats an unsigned executable named that way as an installer and refuses to start it, so those six tests reported `BAD_COMMAND` for anyone running the suite from an ordinary Windows shell - and because CI runners are elevated, nothing caught it as more tests were added. #### Other info (issues closed, discussion etc) Fixes #9748 The gate was verified to fire on a target named after the guard statement, on one in a subdirectory, on `EventDispatcherTest` (the message names the offending substring, since "dispatch" contains "patch"), on a test registered only under `USE_UPDATER` when configuring with the updater off, and to fail loudly if the walk ever stops finding executables. **Test case:** `cmake --build build && ctest --test-dir build` - 92/92 pass; adding a test named e.g. `FooUpdateTest` fails the configure with an explanation. Assisted-by: Claude:claude-opus-5
2026-08-11 08:07:10 +02:00
const QString mpHostname = "Test-ActionSelfRemoval";
Fix: crash when a button uninstalls its own package (#9558) #### Brief overview of PR changes/additions A toolbar or menu button whose Lua script calls `uninstallPackage()` on its own package used to crash Mudlet. `ActionUnit::uninstall()` deleted the package's buttons immediately, but the clicked button is still inside `TAction::execute()`, which reads `this->mpHost` (to restore command-line focus) *after* the script returns - a heap-use-after-free. This applies the same processing-depth deferral already used for triggers/aliases/keys and timers/scripts: while a button's script is running, `uninstall()` deactivates and queues that package's actions instead of deleting them, and the delete happens later in `doCleanup()` once `execute()` has unwound. `ActionUnit` was the last object unit still deleting immediately. The queued deletes are flushed by the button dispatchers once the click returns and by the existing per-line / periodic `doCleanup()` calls in `Host`, and a seen-set guards against a double free from a re-entrant uninstall. #### Motivation for adding to Mudlet It is a hard crash reachable with a completely normal setup - a "reload/update this package" button that reinstalls itself is a common pattern. #### Other info (issues closed, discussion etc) Completes the self-uninstall use-after-free hardening across all object units - a follow-up to #9557 (timers/scripts) and #9383 (triggers/aliases/keys); actions were the one remaining unguarded unit. The crash is reported in Sentry as MUDLET-32 / MUDLET-2S / MUDLET-48. **Test case:** `test/functional_tests/ActionSelfUninstallTest.cpp` (Qt Test, built with AddressSanitizer) builds a synthetic package whose toolbar button uninstalls its own package, gives it a real toolbar widget, invokes the button, and asserts no crash, the package removed, and the deferred action freed cleanly with no double free. It trips a heap-use-after-free in `TAction::execute()` on the pre-fix code and is ASan-clean with the fix. To reproduce by hand: 1. Create a package containing one button whose script is `uninstallPackage("<that package's name>")`. 2. Click the button. 3. Before this PR: Mudlet crashes. After: the package uninstalls cleanly and focus returns to the command line. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Vadim Peretokin <vadim.peretokin@mudlet.org>
2026-07-30 12:02:32 +02:00
const QString mpPort = "4009";
const QString mpLocalhost = "localhost";
// Builds a package the way an installed package with a toolbar button is laid
// out: a master-folder root carrying the package name, a toolbar under it (the
// TEasyButtonBar, on the top bar via mLocation 0), and the actual button under
// that. updateAllToolbars() then gives the button a real TFlipButton, letting a
// test drive the genuine click dispatch path. registerAction() assigns the ids,
// so it must run before setScript() (which bakes the id into the Lua funcname).
// Returns the leaf button whose script uninstalls the package.
TAction* buildSelfUninstallingPackage(Host* host, const QString& packageName)
{
auto* actionUnit = host->getActionUnit();
auto* master = new TAction(packageName, host);
master->mPackageName = packageName;
master->mModuleMasterFolder = true;
master->setIsFolder(true);
master->setIsActive(true);
actionUnit->registerAction(master);
auto* toolbar = new TAction(master, host);
toolbar->setName(qsl("selfUninstallToolbar"));
toolbar->mLocation = 0;
toolbar->setIsActive(true);
actionUnit->registerAction(toolbar);
auto* button = new TAction(toolbar, host);
button->setName(qsl("selfUninstallButton"));
button->setIsActive(true);
actionUnit->registerAction(button);
button->setScript(qsl("uninstallPackage([[%1]])").arg(packageName));
host->mInstalledPackages << packageName;
return button;
}
TFlipButton* findButtonWidget(Host* host, const TAction* action)
{
// TFlipButton has no Q_OBJECT, so findChildren<> it as its QPushButton base
// and downcast.
for (auto* pushButton : host->mpConsole->findChildren<QPushButton*>()) {
auto* pB = dynamic_cast<TFlipButton*>(pushButton);
if (pB && pB->mpTAction == action) {
return pB;
}
}
return nullptr;
}
private slots:
void initTestCase() { initializeQRCResources(); }
void init()
{
mpServer = new TelnetServerStub(qApp);
mpServer->start(mpLocalhost, mpPort.toUShort());
mudlet::start();
mudlet::self()->setupConfig();
mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>("MudletInstanceCoordinator"));
mudlet::self()->init();
mudlet::self()->setStorePasswordsSecurely(false);
deleteProfileDirectory(mpHostname);
}
// A button whose script uninstalls its own package must not crash, must finish
// removing the package, and its deferred TActions must be freed cleanly by
// doCleanup() afterwards. Invokes the TAction::execute() path directly (the
// entry the review brief asks for) and, because the package is given a live
// toolbar first, also drives uninstallPackage()'s internal updateAllToolbars()
// over the half-uninstalled (deactivated but still-linked) package.
void test_selfUninstallingButtonDoesNotCrash()
{
startProfile(mpHostname, mpLocalhost, mpPort);
auto* host = mudlet::self()->getActiveHost();
QVERIFY2(host, "No active host available for the test.");
host->mEchoLuaErrors = true;
auto* actionUnit = host->getActionUnit();
const QString packageName = qsl("TestActionUninstallPkg");
auto* button = buildSelfUninstallingPackage(host, packageName);
const int buttonId = button->getID();
// Give the package a real TFlipButton/TEasyButtonBar so the uninstall runs
// over a live toolbar, not an empty one.
actionUnit->updateAllToolbars();
QVERIFY2(findButtonWidget(host, button), "The button should have a real toolbar widget before the test");
// The crash: pre-fix this frees `button` mid-call, then execute() reads
// this->mpHost. If the guard works we return here with everything intact.
button->execute();
QCOMPARE(actionUnit->processingDepth(), 0);
QVERIFY2(!host->mInstalledPackages.contains(packageName), "The package should have been uninstalled by the button's script");
QVERIFY2(!bufferContains(qsl("Lua error")), "The button's uninstallPackage() script must not have errored");
// Still alive but deferred - deletion was postponed until execute() unwound.
QVERIFY2(actionUnit->getAction(buttonId), "The self-uninstalling button must not be freed while execute() is on the stack");
// Flushing the deferred deletes (as the dispatchers and Host's per-line
// cleanup do) must actually remove them, with no double free.
actionUnit->doCleanup();
QVERIFY2(!actionUnit->getAction(buttonId), "The button should be gone after doCleanup()");
QVERIFY2(actionUnit->findItems(qsl("selfUninstallButton")).empty(), "No trace of the button should remain after cleanup");
// uninstallPackage() defers its profile save to the next event-loop cycle
// (QTimer::singleShot, see Host::uninstallPackage()) and that save runs its
// XML serialization on a background thread. Fire the deferred timer, then
// block until the save has fully finished, so no background save thread is
// still running when cleanup() destroys the host: tearing the host down
// underneath an in-flight save corrupted the heap and crashed on Windows.
QTest::qWait(50);
host->waitForProfileSave();
}
void cleanup()
{
// Defence for the failure path: if an assertion above aborted the test
// before its own drain ran, a profile save uninstallPackage() deferred
// could still be in flight. Let it finish before deleting the host, so
// destruction never races a background save thread (a Windows crash).
if (auto* self = mudlet::self()) {
if (auto* host = self->getActiveHost()) {
QTest::qWait(50);
host->waitForProfileSave();
}
}
delete mpServer;
mpServer = nullptr;
deleteProfileDirectory(mpHostname);
delete mudlet::self();
}
// Starts a profile the way a user would via the GUI (mirrors the helper in
// TFeedTriggersRecursionTest).
void startProfile(const QString& hostname, const QString& address, const QString& port)
{
QTimer::singleShot(0, qApp, [hostname, address, port]() {
mudlet::self()->startAutoLogin({});
QTest::qWait(100);
QTest::mouseClick(mudlet::self()->mpConnectionDialog->new_profile_button, Qt::LeftButton);
QTest::qWait(100);
QTest::keyClicks(QApplication::focusWidget(), hostname);
QTest::qWait(100);
QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab);
QTest::qWait(100);
QTest::keyClicks(QApplication::focusWidget(), address);
QTest::qWait(100);
QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab);
QTest::qWait(100);
QTest::keyClicks(QApplication::focusWidget(), port);
QTest::qWait(100);
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.");
}
auto host = mudlet::self()->getActiveHost();
if (!host) {
QFAIL("No active host available for the test.");
}
QSignalSpy spy2(&(host->mTelnet), &cTelnet::signal_connected);
if (!spy2.wait(2000)) {
QFAIL("Could not connect with the host.");
}
}
QString joinedBuffer()
{
auto console = mudlet::self()->getActiveHost()->mpConsole;
QString allText;
for (int i = 0; i <= console->buffer.getLastLineNumber(); ++i) {
allText.append(console->buffer.line(i)).append(QChar::Space);
}
return allText.simplified();
}
bool bufferContains(const QString& needle) { return joinedBuffer().contains(needle); }
void deleteProfileDirectory(const QString& profileName)
{
const QString path = mudlet::getMudletPath(enums::profileHomePath, profileName);
QDir dir(path);
if (!dir.exists()) {
return;
}
dir.removeRecursively();
}
};
void initializeQRCResources()
{
#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();
}
infrastructure: rename the six tests Windows refuses to launch without elevation (#9753) #### Brief overview of PR changes/additions - Renames the six test executables whose filenames trip Windows' UAC installer detection: `UpdaterChecksumTest` to `ReleaseChecksumPairingTest`, `UpdaterPlatformAssetTest` to `ReleasePlatformAssetTest`, `UpdaterTeardownTest` to `NewReleaseDialogTeardownTest`, `PackageSelfUninstallTest` to `PackageSelfRemovalTest`, `PackageUninstallSaveTeardownTest` to `PackageRemovalSaveTeardownTest`, `ActionSelfUninstallTest` to `ActionSelfRemovalTest`. Assertions are untouched. - Adds a configure-time gate in `test/CMakeLists.txt` that fails with an actionable message if any test executable name contains install, setup, update or patch. It checks both the targets a configuration builds and the test source filenames, so conditionally registered tests cannot slip past it. - Documents the naming rule in `test/README.md`. #### Motivation for adding to Mudlet Windows treats an unsigned executable named that way as an installer and refuses to start it, so those six tests reported `BAD_COMMAND` for anyone running the suite from an ordinary Windows shell - and because CI runners are elevated, nothing caught it as more tests were added. #### Other info (issues closed, discussion etc) Fixes #9748 The gate was verified to fire on a target named after the guard statement, on one in a subdirectory, on `EventDispatcherTest` (the message names the offending substring, since "dispatch" contains "patch"), on a test registered only under `USE_UPDATER` when configuring with the updater off, and to fail loudly if the walk ever stops finding executables. **Test case:** `cmake --build build && ctest --test-dir build` - 92/92 pass; adding a test named e.g. `FooUpdateTest` fails the configure with an explanation. Assisted-by: Claude:claude-opus-5
2026-08-11 08:07:10 +02:00
#include "ActionSelfRemovalTest.moc"
QTEST_MAIN(ActionSelfRemovalTest)