fix(plugins): don't crash on invalid plugin meta (#7166)

This makes our plugin storage use a variant for the different plugin
states:

- UnloadedPlugin (used if the plugin's meta was invalid)
- PluginPtr (a normal valid plugin, which may still contain lua errors)

How to test the crash that doesn't crash with this PR:
Add an invalid permission in your `info.json` file

Co-authored-by: Mm2PL <mm2pl+gh@kotmisia.pl>
This commit is contained in:
pajlada
2026-08-08 13:07:12 +00:00
committed by GitHub
co-authored by Mm2PL
parent 038bed19a7
commit f154a120ca
6 changed files with 192 additions and 77 deletions
+32 -1
View File
@@ -18,10 +18,13 @@
# include <semver/semver.hpp> # include <semver/semver.hpp>
# include <sol/forward.hpp> # include <sol/forward.hpp>
# include <cassert>
# include <memory> # include <memory>
# include <optional> # include <optional>
# include <unordered_map> # include <unordered_map>
# include <unordered_set> # include <unordered_set>
# include <utility>
# include <variant>
# include <vector> # include <vector>
struct lua_State; struct lua_State;
@@ -37,6 +40,25 @@ struct SignalCallback;
namespace chatterino { namespace chatterino {
/// A plugin that hasn't been loaded.
///
/// Most likely, its metadata is invalid.
class UnloadedPlugin
{
public:
QString id;
PluginMeta meta;
QDir loadDirectory;
UnloadedPlugin(QString id_, PluginMeta meta_, const QDir &loadDirectory_)
: id(std::move(id_))
, meta(std::move(meta_))
, loadDirectory(loadDirectory_)
{
}
};
/// A plugin with a valid lua state
class Plugin class Plugin
{ {
public: public:
@@ -49,8 +71,12 @@ public:
, meta(std::move(meta)) , meta(std::move(meta))
, loadDirectory_(loadDirectory) , loadDirectory_(loadDirectory)
, state_(state) , state_(state)
, selfRef_(state ? this : nullptr) , selfRef_(this)
{ {
// The PluginMeta here must be valid, otherwise it should be initialized
// as an UnloadedPlugin
assert(this->meta.isValid());
assert(this->state_ != nullptr);
} }
~Plugin(); ~Plugin();
@@ -148,5 +174,10 @@ private:
friend class PluginController; friend class PluginController;
friend class PluginControllerAccess; // this is for tests friend class PluginControllerAccess; // this is for tests
}; };
using PluginPtr = std::unique_ptr<Plugin>;
using AnyPlugin = std::variant<PluginPtr, UnloadedPlugin>;
} // namespace chatterino } // namespace chatterino
#endif #endif
+61 -11
View File
@@ -32,6 +32,7 @@
# include "singletons/Paths.hpp" # include "singletons/Paths.hpp"
# include "singletons/Settings.hpp" # include "singletons/Settings.hpp"
# include "singletons/WindowManager.hpp" # include "singletons/WindowManager.hpp"
# include "util/Variant.hpp"
# include "widgets/splits/SplitContainer.hpp" # include "widgets/splits/SplitContainer.hpp"
# include "widgets/Window.hpp" # include "widgets/Window.hpp"
@@ -131,9 +132,10 @@ bool PluginController::tryLoadFromDir(const QDir &pluginDir)
{ {
qCWarning(chatterinoLua) << "- " << why; qCWarning(chatterinoLua) << "- " << why;
} }
auto plugin = std::make_unique<Plugin>(pluginDir.dirName(), nullptr,
meta, pluginDir); this->plugins_.insert(
this->plugins_.insert({pluginDir.dirName(), std::move(plugin)}); {pluginDir.dirName(),
UnloadedPlugin{pluginDir.dirName(), meta, pluginDir}});
return false; return false;
} }
this->load(index, pluginDir, meta); this->load(index, pluginDir, meta);
@@ -357,11 +359,26 @@ bool PluginController::reload(const QString &id)
return false; return false;
} }
for (const auto &[cmd, _] : it->second->ownedCommands) if (const auto *oPlugin = std::get_if<PluginPtr>(&it->second))
{ {
getApp()->getCommands()->unregisterPluginCommand(cmd); const auto &plugin = *oPlugin;
for (const auto &[cmd, _] : plugin->ownedCommands)
{
getApp()->getCommands()->unregisterPluginCommand(cmd);
}
} }
QDir loadDir = it->second->loadDirectory_;
const auto loadDir = std::visit(variant::Overloaded{
[&](const PluginPtr &plugin) {
return plugin->loadDirectory_;
},
[&](const UnloadedPlugin &plugin) {
return plugin.loadDirectory;
},
},
it->second);
// Since Plugin owns the state, it will clean up everything related to it // Since Plugin owns the state, it will clean up everything related to it
this->plugins_.erase(id); this->plugins_.erase(id);
this->queueChangeNotification(); this->queueChangeNotification();
@@ -372,8 +389,15 @@ bool PluginController::reload(const QString &id)
QString PluginController::tryExecPluginCommand(const QString &commandName, QString PluginController::tryExecPluginCommand(const QString &commandName,
const CommandContext &ctx) const CommandContext &ctx)
{ {
for (auto &[name, plugin] : this->plugins_) for (const auto &[name, anyPlugin] : this->allPlugins())
{ {
const auto *oPl = std::get_if<PluginPtr>(&anyPlugin);
if (oPl == nullptr)
{
continue;
}
const auto &plugin = *oPl;
if (auto it = plugin->ownedCommands.find(commandName); if (auto it = plugin->ownedCommands.find(commandName);
it != plugin->ownedCommands.end()) it != plugin->ownedCommands.end())
{ {
@@ -421,8 +445,15 @@ Plugin *PluginController::getPluginByStatePtr(lua_State *L)
auto *mainL = lua_tothread(L, -1); auto *mainL = lua_tothread(L, -1);
lua_pop(L, 1); lua_pop(L, 1);
L = mainL; L = mainL;
for (auto &[name, plugin] : this->plugins_) for (const auto &[name, anyPlugin] : this->allPlugins())
{ {
const auto *oPl = std::get_if<PluginPtr>(&anyPlugin);
if (oPl == nullptr)
{
continue;
}
const auto &plugin = *oPl;
if (plugin->state_ == L) if (plugin->state_ == L)
{ {
return plugin.get(); return plugin.get();
@@ -431,8 +462,19 @@ Plugin *PluginController::getPluginByStatePtr(lua_State *L)
return nullptr; return nullptr;
} }
const std::map<QString, std::unique_ptr<Plugin>> &PluginController::plugins() void PluginController::forEachPlugin(
const FunctionRef<void(const std::unique_ptr<Plugin> &)> cb) const
{
for (const auto &[_, anyPlugin] : this->allPlugins())
{
if (const auto *plugin = std::get_if<PluginPtr>(&anyPlugin))
{
cb(*plugin);
}
}
}
const std::map<QString, AnyPlugin> &PluginController::allPlugins() const
{ {
return this->plugins_; return this->plugins_;
} }
@@ -443,8 +485,16 @@ std::pair<bool, QStringList> PluginController::updateCustomCompletions(
{ {
QStringList results; QStringList results;
for (const auto &[name, pl] : this->plugins()) for (const auto &[name, anyPlugin] : this->allPlugins())
{ {
const auto *oPl = std::get_if<PluginPtr>(&anyPlugin);
if (oPl == nullptr)
{
continue;
}
const auto &pl = *oPl;
if (!pl->error().isNull() || pl->state_ == nullptr) if (!pl->error().isNull() || pl->state_ == nullptr)
{ {
continue; continue;
+7 -3
View File
@@ -9,6 +9,7 @@
# include "common/websockets/WebSocketPool.hpp" # include "common/websockets/WebSocketPool.hpp"
# include "controllers/commands/CommandContext.hpp" # include "controllers/commands/CommandContext.hpp"
# include "controllers/plugins/Plugin.hpp" # include "controllers/plugins/Plugin.hpp"
# include "util/FunctionRef.hpp"
# include <pajlada/signals/signal.hpp> # include <pajlada/signals/signal.hpp>
# include <QDir> # include <QDir>
@@ -45,8 +46,11 @@ public:
// This is required to be public because of c functions // This is required to be public because of c functions
Plugin *getPluginByStatePtr(lua_State *L); Plugin *getPluginByStatePtr(lua_State *L);
// TODO: make a function that iterates plugins that aren't errored/enabled /// Run `cb` on every loaded plugin, including those with load errors
const std::map<QString, std::unique_ptr<Plugin>> &plugins() const; void forEachPlugin(
FunctionRef<void(const std::unique_ptr<Plugin> &)>) const;
const std::map<QString, AnyPlugin> &allPlugins() const;
/** /**
* @brief Reload plugin given by id * @brief Reload plugin given by id
@@ -86,7 +90,7 @@ private:
void queueChangeNotification(); void queueChangeNotification();
std::map<QString, std::unique_ptr<Plugin>> plugins_; std::map<QString, AnyPlugin> plugins_;
WebSocketPool webSocketPool_; WebSocketPool webSocketPool_;
std::vector< std::vector<
+11 -3
View File
@@ -665,16 +665,24 @@ void PluginRepl::log(std::optional<lua::api::LogLevel> level,
void PluginRepl::tryUpdate() void PluginRepl::tryUpdate()
{ {
auto it = getApp()->getPlugins()->plugins().find(this->id); auto it = getApp()->getPlugins()->allPlugins().find(this->id);
if (it == getApp()->getPlugins()->plugins().end()) if (it == getApp()->getPlugins()->allPlugins().end())
{ {
return; return;
} }
const auto *oPl = std::get_if<PluginPtr>(&it->second);
if (oPl == nullptr)
{
return;
}
const auto &pl = *oPl;
if (!PluginController::isPluginEnabled(this->id)) if (!PluginController::isPluginEnabled(this->id))
{ {
return; return;
} }
this->setPlugin(it->second.get()); this->setPlugin(pl.get());
} }
void PluginRepl::setPlugin(Plugin *plugin) void PluginRepl::setPlugin(Plugin *plugin)
+80 -58
View File
@@ -8,12 +8,14 @@
# include "Application.hpp" # include "Application.hpp"
# include "common/Args.hpp" # include "common/Args.hpp"
# include "controllers/accounts/AccountController.hpp" # include "controllers/accounts/AccountController.hpp"
# include "controllers/plugins/Plugin.hpp"
# include "controllers/plugins/PluginController.hpp" # include "controllers/plugins/PluginController.hpp"
# include "singletons/Paths.hpp" # include "singletons/Paths.hpp"
# include "singletons/Settings.hpp" # include "singletons/Settings.hpp"
# include "util/Helpers.hpp" # include "util/Helpers.hpp"
# include "util/LayoutCreator.hpp" # include "util/LayoutCreator.hpp"
# include "util/RemoveScrollAreaBackground.hpp" # include "util/RemoveScrollAreaBackground.hpp"
# include "util/Variant.hpp"
# include "widgets/PluginRepl.hpp" # include "widgets/PluginRepl.hpp"
# include "widgets/settingspages/SettingWidget.hpp" # include "widgets/settingspages/SettingWidget.hpp"
@@ -121,54 +123,53 @@ void PluginsPage::rebuildContent()
this->scrollAreaWidget_.append(this->dataFrame_); this->scrollAreaWidget_.append(this->dataFrame_);
auto layout = frame.setLayoutType<QVBoxLayout>(); auto layout = frame.setLayoutType<QVBoxLayout>();
layout->setParent(this->dataFrame_); layout->setParent(this->dataFrame_);
for (const auto &[id, plugin] : getApp()->getPlugins()->plugins()) for (const auto &[id, plugin] : getApp()->getPlugins()->allPlugins())
{ {
const auto &meta = std::visit(variant::Overloaded{
[&](const PluginPtr &plugin) {
return plugin->meta;
},
[&](const UnloadedPlugin &plugin) {
return plugin.meta;
},
},
plugin);
auto groupHeaderText = auto groupHeaderText =
QString("%1 (%2, from %3)") QString("%1 (%2, from %3)")
.arg(plugin->meta.name, .arg(meta.name,
QString::fromStdString(plugin->meta.version.to_string()), QString::fromStdString(meta.version.to_string()), id);
id);
auto groupBox = layout.emplace<QGroupBox>(groupHeaderText); auto groupBox = layout.emplace<QGroupBox>(groupHeaderText);
groupBox->setParent(this->dataFrame_); groupBox->setParent(this->dataFrame_);
auto pluginEntry = groupBox.setLayoutType<QFormLayout>(); auto pluginEntry = groupBox.setLayoutType<QFormLayout>();
pluginEntry->setParent(groupBox.getElement()); pluginEntry->setParent(groupBox.getElement());
if (!plugin->meta.isValid()) if (!meta.isValid())
{ {
QString errors = "<ul>"; QString errors = "<ul>";
for (const auto &err : plugin->meta.errors) for (const auto &err : meta.errors)
{ {
errors += "<li>" + err.toHtmlEscaped() + "</li>"; errors += "<li>" + err.toHtmlEscaped() + "</li>";
} }
errors += "</ul>"; errors += "</ul>";
auto *warningLabel = new QLabel( auto *warningLabel = new QLabel("There were errors while loading "
"There were errors while loading metadata for this plugin:" + "metadata for this plugin:" +
errors, errors,
this->dataFrame_); this->dataFrame_);
warningLabel->setTextFormat(Qt::RichText); warningLabel->setTextFormat(Qt::RichText);
warningLabel->setStyleSheet("color: #f00"); warningLabel->setStyleSheet("color: #f00");
pluginEntry->addRow(warningLabel); pluginEntry->addRow(warningLabel);
} }
if (!plugin->error().isNull())
{
auto *errorLabel =
new QLabel("There was an error while loading this plugin: " +
plugin->error(),
this->dataFrame_);
errorLabel->setStyleSheet("color: #f00");
errorLabel->setWordWrap(true);
pluginEntry->addRow(errorLabel);
}
auto *description = auto *description = new QLabel(meta.description, this->dataFrame_);
new QLabel(plugin->meta.description, this->dataFrame_);
description->setWordWrap(true); description->setWordWrap(true);
description->setStyleSheet("color: #bbb"); description->setStyleSheet("color: #bbb");
pluginEntry->addRow(description); pluginEntry->addRow(description);
QString authorsTxt; QString authorsTxt;
for (const auto &author : plugin->meta.authors) for (const auto &author : meta.authors)
{ {
if (!authorsTxt.isEmpty()) if (!authorsTxt.isEmpty())
{ {
@@ -180,32 +181,49 @@ void PluginsPage::rebuildContent()
pluginEntry->addRow("Authors", pluginEntry->addRow("Authors",
new QLabel(authorsTxt, this->dataFrame_)); new QLabel(authorsTxt, this->dataFrame_));
if (!plugin->meta.homepage.isEmpty()) if (!meta.homepage.isEmpty())
{ {
auto *homepage = new QLabel(formatRichLink(plugin->meta.homepage), auto *homepage =
this->dataFrame_); new QLabel(formatRichLink(meta.homepage), this->dataFrame_);
homepage->setOpenExternalLinks(true); homepage->setOpenExternalLinks(true);
pluginEntry->addRow("Homepage", homepage); pluginEntry->addRow("Homepage", homepage);
} }
pluginEntry->addRow("License", pluginEntry->addRow("License",
new QLabel(plugin->meta.license, this->dataFrame_)); new QLabel(meta.license, this->dataFrame_));
QString commandsTxt; if (const auto *oValidPlugin = std::get_if<PluginPtr>(&plugin))
for (const auto &cmdName : plugin->listRegisteredCommands())
{ {
if (!commandsTxt.isEmpty()) const auto &validPlugin = *oValidPlugin;
if (!validPlugin->error().isNull())
{ {
commandsTxt += ", "; auto *errorLabel = new QLabel(
"There was an error while loading this plugin: " +
validPlugin->error(),
this->dataFrame_);
errorLabel->setStyleSheet("color: #f00");
errorLabel->setWordWrap(true);
pluginEntry->addRow(errorLabel);
} }
commandsTxt += cmdName; QString commandsTxt;
for (const auto &cmdName : validPlugin->listRegisteredCommands())
{
if (!commandsTxt.isEmpty())
{
commandsTxt += ", ";
}
commandsTxt += cmdName;
}
pluginEntry->addRow("Commands",
new QLabel(commandsTxt, this->dataFrame_));
} }
pluginEntry->addRow("Commands",
new QLabel(commandsTxt, this->dataFrame_)); if (!meta.permissions.empty())
if (!plugin->meta.permissions.empty())
{ {
QString perms = "<ul>"; QString perms = "<ul>";
for (const auto &perm : plugin->meta.permissions) for (const auto &perm : meta.permissions)
{ {
perms += "<li>" + perm.toHtml() + "</li>"; perms += "<li>" + perm.toHtml() + "</li>";
} }
@@ -217,30 +235,34 @@ void PluginsPage::rebuildContent()
pluginEntry->addRow(lbl); pluginEntry->addRow(lbl);
} }
if (plugin->meta.isValid()) QString toggleTxt = "Enable";
if (PluginController::isPluginEnabled(id))
{ {
QString toggleTxt = "Enable"; toggleTxt = "Disable";
if (PluginController::isPluginEnabled(id)) }
{
toggleTxt = "Disable";
}
auto *toggleButton = new QPushButton(toggleTxt, this->dataFrame_); auto *toggleButton = new QPushButton(toggleTxt, this->dataFrame_);
QObject::connect( QObject::connect(toggleButton, &QPushButton::pressed, [name = id]() {
toggleButton, &QPushButton::pressed, [name = id, this]() { QStringList val = getSettings()->enabledPlugins;
QStringList val = getSettings()->enabledPlugins; if (PluginController::isPluginEnabled(name))
if (PluginController::isPluginEnabled(name)) {
{ val.removeAll(name);
val.removeAll(name); }
} else
else {
{ val.push_back(name);
val.push_back(name); }
} getSettings()->enabledPlugins.setValue(val);
getSettings()->enabledPlugins.setValue(val); getApp()->getPlugins()->reload(name);
getApp()->getPlugins()->reload(name); });
}); pluginEntry->addRow(toggleButton);
pluginEntry->addRow(toggleButton); if (meta.isValid())
{
toggleButton->setEnabled(true);
}
else
{
toggleButton->setEnabled(false);
} }
auto *reloadButton = new QPushButton("Reload", this->dataFrame_); auto *reloadButton = new QPushButton("Reload", this->dataFrame_);
+1 -1
View File
@@ -206,7 +206,7 @@ public:
getApp()->getPlugins()->openLibrariesFor(plugin); getApp()->getPlugins()->openLibrariesFor(plugin);
} }
static std::map<QString, std::unique_ptr<Plugin>> &plugins() static std::map<QString, AnyPlugin> &plugins()
{ {
return getApp()->getPlugins()->plugins_; return getApp()->getPlugins()->plugins_;
} }