Files
ReSkate/Extension/Plugins/plugin_api.cpp
T
AyeZeeBB 6d542536e1 Plugins: sandboxed Luau plugins in mods, phase 1 (the host, and the base, console and storage APIs)
A mod with a reskate-plugin.json is a plugin: Luau scripts that run in their own VM, with only
the reskate API ReSkate registers for them. Luau 0.741 is vendored in External/luau (the VM and
compiler only: no native code generation, no loadstring, io or C modules).

- Limits: 64 MB of memory, 50 ms a call, 2 ms a tick on average (over it for 3 s stops it),
  more than 5 errors in 10 s stops it, 50 log lines a second. A plugin over a limit cannot catch
  it in a pcall and carry on.
- API: reskate.log, on (load, unload, tick, level_loaded, level_unloading), timers, the level,
  its own package's files and JSON, require inside the package; console.command and print
  ("console"); storage get, set, add and keys ("storage"), 1 MB in
  %LOCALAPPDATA%\ReSkate\Plugins\<mod>\storage.json.
- Found every 2 s among the mods mods.json leaves on: switching a mod, or changing a plugin's
  files, loads, unloads or reloads it without a restart. A package holding a program is refused.
- plugin list, commands, reload, off and on in the console. A plugin cannot take a name of
  ReSkate's own commands.
- Tests: DINGOSDK_BUILD_PLUGIN_TESTS, including plugins written to break the sandbox.
2026-10-19 11:55:27 -05:00

509 lines
22 KiB
C++

#include "plugin_api.h"
#include "lua.h"
#include "lualib.h"
#include <algorithm>
#include <array>
#include <cmath>
#include <format>
#include <optional>
// The reskate API, v1 base set plus console and storage (docs: ReSkate Plugin System Design).
// Every function checks what it is given before acting on it, and no value a plugin passes
// reaches anything outside its own VM unchecked. Errors are raised the way Lua reports them:
// luaL_error, or a C++ exception, which Luau turns into a Lua error with its message.
namespace dingosdk::plugins {
namespace {
constexpr std::array<std::string_view, 5> events{"load", "unload", "tick", "level_loaded", "level_unloading"};
constexpr std::size_t maximum_key = 128;
constexpr std::size_t maximum_command = 48;
constexpr std::size_t maximum_listing = 1000;
// A plugin that caught its own memory or time error in a pcall gets no further: nothing it asks
// for is done, and the next safe point stops it.
Plugin& plugin_of(lua_State* L) {
auto& plugin = *static_cast<Plugin*>(lua_callbacks(L)->userdata);
if (plugin.halted()) luaL_error(L, "the plugin was stopped");
return plugin;
}
// The table being filled is on top of the stack.
void set_function(lua_State* L, const char* name, lua_CFunction function) {
lua_pushcfunction(L, function, name);
lua_setfield(L, -2, name);
}
std::string text_of(lua_State* L, int index) {
std::size_t length = 0;
const char* text = luaL_tolstring(L, index, &length);
std::string result(text, length);
lua_pop(L, 1);
return result;
}
// What turning Lua into JSON may cost outside the VM's own heap.
struct Budget {
std::size_t values = 1u << 20;
std::size_t bytes = 16u << 20;
void spend(std::size_t count, std::size_t size) {
if (count > values || size > bytes) throw std::runtime_error("the value is too large to turn into JSON");
values -= count;
bytes -= size;
}
};
Json to_json(lua_State* L, int index, int depth, Budget& budget) {
if (depth > 32) throw std::runtime_error("tables nested deeper than 32 cannot be turned into JSON");
index = lua_absindex(L, index);
budget.spend(1, 0);
switch (lua_type(L, index)) {
case LUA_TNIL: return nullptr;
case LUA_TBOOLEAN: return lua_toboolean(L, index) != 0;
case LUA_TNUMBER: {
const double value = lua_tonumber(L, index);
if (!std::isfinite(value)) throw std::runtime_error("JSON has no NaN or infinity");
if (value == std::floor(value) && std::fabs(value) <= 9007199254740992.0) return static_cast<std::int64_t>(value);
return value;
}
case LUA_TSTRING: {
std::size_t length = 0;
const char* text = lua_tolstring(L, index, &length);
budget.spend(0, length);
return std::string(text, length);
}
case LUA_TTABLE: {
luaL_checkstack(L, 4, "tables nested too deep");
const int length = lua_objlen(L, index);
bool list = length > 0;
int count = 0;
lua_pushnil(L);
while (lua_next(L, index)) {
++count;
if (lua_type(L, -2) != LUA_TNUMBER) list = false;
else {
const double key = lua_tonumber(L, -2);
if (key != std::floor(key) || key < 1 || key > length) list = false;
}
lua_pop(L, 1);
}
if (list && count == length) {
auto result = Json::array();
for (int i = 1; i <= length; ++i) {
lua_rawgeti(L, index, i);
result.push_back(to_json(L, -1, depth + 1, budget));
lua_pop(L, 1);
}
return result;
}
auto result = Json::object();
lua_pushnil(L);
while (lua_next(L, index)) {
if (lua_type(L, -2) != LUA_TSTRING)
throw std::runtime_error("only a list (keys 1 to n) or a table with string keys can be turned into JSON");
std::size_t size = 0;
const char* key = lua_tolstring(L, -2, &size);
budget.spend(0, size);
result[std::string_view(key, size)] = to_json(L, -1, depth + 1, budget);
lua_pop(L, 1);
}
return result;
}
default:
throw std::runtime_error(std::string("a ") + lua_typename(L, lua_type(L, index)) + " cannot be turned into JSON");
}
}
void push_json_value(lua_State* L, const Json& value, int depth) {
luaL_checkstack(L, 4, "JSON nested too deep");
if (depth > 64) throw std::runtime_error("JSON nested too deep");
if (value.is_null()) lua_pushnil(L);
else if (value.is_boolean()) lua_pushboolean(L, value.get<bool>());
else if (value.is_number()) lua_pushnumber(L, value.get<double>());
else if (value.is_string()) lua_pushlstring(L, value.string().data(), value.string().size());
else if (value.is_array()) {
lua_createtable(L, static_cast<int>(value.size()), 0);
int index = 0;
for (const auto& item : value) {
push_json_value(L, item, depth + 1);
lua_rawseti(L, -2, ++index);
}
} else {
lua_createtable(L, 0, static_cast<int>(value.size()));
for (const auto& [key, item] : value.items()) {
lua_pushlstring(L, key.data(), key.size());
push_json_value(L, item, depth + 1);
lua_rawset(L, -3);
}
}
}
void need(lua_State* L, Plugin& plugin, const char* permission, const char* what) {
if (!plugin.has(permission))
luaL_error(L, "%s needs the \"%s\" permission in reskate-plugin.json", what, permission);
}
std::string key_of(lua_State* L, int index) {
std::size_t length = 0;
const char* key = luaL_checklstring(L, index, &length);
if (length == 0 || length > maximum_key) luaL_error(L, "a storage key must be 1 to %d characters", static_cast<int>(maximum_key));
return std::string(key, length);
}
// A file or folder of the plugin's own package, never outside it: the path is checked as text,
// then once more after links and junctions are resolved.
std::filesystem::path package_path(lua_State* L, Plugin& plugin, std::string_view path, bool folder) {
std::string relative = folder && path.empty() ? std::string{} : safe_relative_path(path);
if (relative.empty() && !(folder && path.empty()))
luaL_error(L, "\"%s\" is not a path inside the plugin's package (use / between folders, no ..)", std::string(path).c_str());
const auto root = plugin.directory();
const auto full = relative.empty() ? root : root / std::filesystem::path(relative);
std::error_code code;
const auto real_root = std::filesystem::weakly_canonical(root, code);
const auto real = std::filesystem::weakly_canonical(full, code);
const auto inside = real.lexically_relative(real_root);
if (code || (!relative.empty() && (inside.empty() || *inside.begin() == ".."))) luaL_error(L, "that path leads outside the package");
return full;
}
}
struct ApiFunctions {
static int log(lua_State* L) {
auto& plugin = plugin_of(L);
const auto message = text_of(L, 1);
const std::string level = luaL_optstring(L, 2, "info");
if (level != "info" && level != "warn" && level != "error") luaL_argerror(L, 2, "the level must be \"info\", \"warn\" or \"error\"");
plugin.log(level == "error" ? LogLevel::error : level == "warn" ? LogLevel::warn : LogLevel::info, message);
return 0;
}
static int print(lua_State* L) {
std::string line;
const int count = lua_gettop(L);
for (int i = 1; i <= count; ++i) {
if (i > 1) line += '\t';
line += text_of(L, i);
}
plugin_of(L).log(LogLevel::info, line);
return 0;
}
static int on(lua_State* L) {
auto& plugin = plugin_of(L);
const std::string event = luaL_checkstring(L, 1);
luaL_checktype(L, 2, LUA_TFUNCTION);
if (std::find(events.begin(), events.end(), event) == events.end())
luaL_error(L, "there is no event \"%s\" in this ReSkate", event.c_str());
auto& list = plugin.handlers_[event];
if (list.size() >= plugin.limits_.handlers) luaL_error(L, "more than %d handlers for one event", static_cast<int>(plugin.limits_.handlers));
lua_pushvalue(L, 2);
list.push_back(lua_ref(L, -1));
lua_pop(L, 1);
return 0;
}
static int now(lua_State* L) {
lua_pushnumber(L, plugin_of(L).services_.now_ms());
return 1;
}
static int timer(lua_State* L, bool repeat) {
auto& plugin = plugin_of(L);
const double ms = luaL_checknumber(L, 1);
luaL_checktype(L, 2, LUA_TFUNCTION);
if (!std::isfinite(ms) || ms < (repeat ? 1 : 0)) luaL_argerror(L, 1, repeat ? "must be 1 ms or more" : "must be 0 ms or more");
if (plugin.timers_.size() >= plugin.limits_.timers) luaL_error(L, "more than %d timers", static_cast<int>(plugin.limits_.timers));
lua_pushvalue(L, 2);
const int function = lua_ref(L, -1);
lua_pop(L, 1);
const int id = plugin.next_timer_++;
plugin.timers_[id] = {plugin.services_.now_ms() + ms, repeat ? ms : 0, function};
lua_pushinteger(L, id);
return 1;
}
static int after(lua_State* L) { return timer(L, false); }
static int every(lua_State* L) { return timer(L, true); }
static int cancel(lua_State* L) {
auto& plugin = plugin_of(L);
const auto found = plugin.timers_.find(luaL_checkinteger(L, 1));
if (found != plugin.timers_.end()) {
lua_unref(L, found->second.function);
plugin.timers_.erase(found);
}
return 0;
}
static int level(lua_State* L) {
const auto name = plugin_of(L).services_.level();
if (name.empty()) {
lua_pushnil(L);
return 1;
}
lua_createtable(L, 0, 1);
lua_pushlstring(L, name.data(), name.size());
lua_setfield(L, -2, "name");
return 1;
}
static int read(lua_State* L) {
auto& plugin = plugin_of(L);
const auto file = package_path(L, plugin, luaL_checkstring(L, 1), false);
std::string error;
const auto text = read_package_file(file, plugin.limits_.file_bytes, error);
if (error == "not found") {
lua_pushnil(L);
return 1;
}
if (!error.empty()) luaL_error(L, "%s %s", luaL_checkstring(L, 1), error.c_str());
lua_pushlstring(L, text.data(), text.size());
return 1;
}
static int read_json(lua_State* L) {
auto& plugin = plugin_of(L);
const auto file = package_path(L, plugin, luaL_checkstring(L, 1), false);
std::string error;
const auto text = read_package_file(file, plugin.limits_.file_bytes, error);
if (error == "not found") {
lua_pushnil(L);
return 1;
}
if (!error.empty()) luaL_error(L, "%s %s", luaL_checkstring(L, 1), error.c_str());
push_json(L, Json::parse(text, {plugin.limits_.file_bytes, 64, 1u << 20}));
return 1;
}
static int list(lua_State* L) {
auto& plugin = plugin_of(L);
const auto folder = package_path(L, plugin, luaL_optstring(L, 1, ""), true);
std::vector<std::string> names;
std::error_code code;
for (std::filesystem::directory_iterator it(folder, code), end; !code && it != end; it.increment(code)) {
const auto name = it->path().filename().wstring();
if (!std::all_of(name.begin(), name.end(), [](wchar_t c) { return c >= 0x20 && c < 0x7f; })) continue;
std::string ascii;
for (const wchar_t c : name) ascii += static_cast<char>(c);
if (it->is_directory(code)) ascii += '/';
names.push_back(std::move(ascii));
if (names.size() > maximum_listing) luaL_error(L, "the folder holds more than %d entries", static_cast<int>(maximum_listing));
}
std::sort(names.begin(), names.end());
lua_createtable(L, static_cast<int>(names.size()), 0);
for (std::size_t i = 0; i < names.size(); ++i) {
lua_pushlstring(L, names[i].data(), names[i].size());
lua_rawseti(L, -2, static_cast<int>(i + 1));
}
return 1;
}
static int encode(lua_State* L) {
luaL_checkany(L, 1);
const auto text = lua_to_json(L, 1).dump(lua_toboolean(L, 2) ? 2 : -1);
lua_pushlstring(L, text.data(), text.size());
return 1;
}
static int decode(lua_State* L) {
std::size_t length = 0;
const char* text = luaL_checklstring(L, 1, &length);
push_json(L, Json::parse(std::string_view(text, length), {16u << 20, 64, 1u << 20}));
return 1;
}
static int command(lua_State* L) {
auto& plugin = plugin_of(L);
need(L, plugin, "console", "reskate.console.command");
std::size_t length = 0;
const char* text = luaL_checklstring(L, 1, &length);
const std::string help = luaL_checkstring(L, 2);
luaL_checktype(L, 3, LUA_TFUNCTION);
const std::string name(text, length);
// Lowercase words of letters, digits, _ and -, one space between them, at most three.
bool valid = !name.empty() && name.size() <= maximum_command && name.front() != ' ' && name.back() != ' ' &&
name.find(" ") == std::string::npos && std::count(name.begin(), name.end(), ' ') < 3;
for (const char c : name) valid = valid && ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '-' || c == ' ');
if (!valid) luaL_error(L, "\"%s\" is not a command name: 1 to 3 lowercase words of a-z, 0-9, _ and -", name.c_str());
if (plugin.commands_.size() >= plugin.limits_.commands) luaL_error(L, "more than %d commands", static_cast<int>(plugin.limits_.commands));
if (plugin.commands_.contains(name)) luaL_error(L, "the command \"%s\" is registered already", name.c_str());
if (const auto refusal = plugin.services_.claim_command(plugin.id(), name); !refusal.empty())
luaL_error(L, "%s", refusal.c_str());
lua_pushvalue(L, 3);
plugin.commands_[name] = {help.substr(0, 200), lua_ref(L, -1)};
lua_pop(L, 1);
return 0;
}
static int console_print(lua_State* L) {
auto& plugin = plugin_of(L);
need(L, plugin, "console", "reskate.console.print");
plugin.print(text_of(L, 1));
return 0;
}
static int storage_get(lua_State* L) {
auto& plugin = plugin_of(L);
need(L, plugin, "storage", "reskate.storage");
const auto key = key_of(L, 1);
const auto found = plugin.storage_.find(key);
if (found == plugin.storage_.end()) {
lua_pushnil(L);
return 1;
}
push_json(L, *found);
return 1;
}
// Sets a key and checks the whole store against the quota; the value it had comes back on failure.
static void store(lua_State* L, Plugin& plugin, const std::string& key, Json value) {
std::optional<Json> previous;
if (const auto found = plugin.storage_.find(key); found != plugin.storage_.end()) previous = *found;
if (value.is_null()) plugin.storage_.erase(key);
else plugin.storage_[key] = std::move(value);
if (const auto size = plugin.storage_.dump().size(); size > plugin.limits_.storage_bytes) {
if (previous) plugin.storage_[key] = std::move(*previous);
else plugin.storage_.erase(key);
luaL_error(L, "storage is full: that would make it %d KB, and a plugin has %d KB", static_cast<int>(size >> 10),
static_cast<int>(plugin.limits_.storage_bytes >> 10));
}
plugin.storage_dirty_ = true;
}
static int storage_set(lua_State* L) {
auto& plugin = plugin_of(L);
need(L, plugin, "storage", "reskate.storage");
const auto key = key_of(L, 1);
store(L, plugin, key, lua_to_json(L, 2));
return 0;
}
static int storage_add(lua_State* L) {
auto& plugin = plugin_of(L);
need(L, plugin, "storage", "reskate.storage");
const auto key = key_of(L, 1);
const double by = luaL_optnumber(L, 2, 1);
if (!std::isfinite(by)) luaL_argerror(L, 2, "must be a finite number");
double value = 0;
if (const auto found = plugin.storage_.find(key); found != plugin.storage_.end()) {
if (!found->is_number()) luaL_error(L, "storage key \"%s\" does not hold a number", key.c_str());
value = found->get<double>();
}
value += by;
store(L, plugin, key, value == std::floor(value) && std::fabs(value) <= 9007199254740992.0
? Json(static_cast<std::int64_t>(value)) : Json(value));
lua_pushnumber(L, value);
return 1;
}
static int storage_keys(lua_State* L) {
auto& plugin = plugin_of(L);
need(L, plugin, "storage", "reskate.storage");
lua_createtable(L, static_cast<int>(plugin.storage_.size()), 0);
int index = 0;
for (const auto& item : plugin.storage_.items()) {
lua_pushlstring(L, item.first.data(), item.first.size());
lua_rawseti(L, -2, ++index);
}
return 1;
}
// Modules come from the plugin's own package, beside its entry: require("./rules") or
// require("rules") is <entry folder>/rules.luau or rules.lua. Each runs once.
static int require(lua_State* L) {
auto& plugin = plugin_of(L);
std::string name = luaL_checkstring(L, 1);
if (name.starts_with("./")) name.erase(0, 2);
const auto relative = safe_relative_path(name);
if (relative.empty()) luaL_error(L, "require(\"%s\"): a module is a path inside the plugin's package, with no ..", name.c_str());
const auto in_root = [&](const std::string& path) { return plugin.code_root_.empty() ? path : plugin.code_root_ + "/" + path; };
std::vector<std::string> candidates;
if (relative.ends_with(".lua") || relative.ends_with(".luau")) candidates.push_back(in_root(relative));
else {
candidates.push_back(in_root(relative + ".luau"));
candidates.push_back(in_root(relative + ".lua"));
}
std::string key;
std::filesystem::path file;
for (const auto& candidate : candidates) {
const auto path = package_path(L, plugin, candidate, false);
std::error_code code;
if (std::filesystem::is_regular_file(path, code)) {
key = candidate;
file = path;
break;
}
}
if (key.empty()) luaL_error(L, "require(\"%s\"): there is no %s", name.c_str(), candidates.front().c_str());
lua_getref(L, plugin.modules_);
lua_getfield(L, -1, key.c_str());
if (!lua_isnil(L, -1)) return 1;
lua_pop(L, 1);
if (plugin.loading_modules_.contains(key)) luaL_error(L, "require(\"%s\"): it requires itself, through another module", name.c_str());
plugin.loading_modules_.insert(key);
struct Done {
Plugin& plugin;
std::string key;
~Done() { plugin.loading_modules_.erase(key); }
} done{plugin, key};
if (!plugin.load_chunk(L, key, file)) lua_error(L);
lua_call(L, 0, 1);
if (lua_isnil(L, -1)) {
lua_pop(L, 1);
lua_pushboolean(L, 1);
}
lua_pushvalue(L, -1);
lua_setfield(L, -3, key.c_str());
return 1;
}
};
Json lua_to_json(lua_State* L, int index) {
Budget budget;
return to_json(L, index, 0, budget);
}
void push_json(lua_State* L, const Json& value) { push_json_value(L, value, 0); }
void Api::install(Plugin& plugin, lua_State* L) {
const auto readonly = [&] { lua_setreadonly(L, -1, true); };
lua_createtable(L, 0, 16);
lua_pushinteger(L, api_version);
lua_setfield(L, -2, "api_version");
lua_pushlstring(L, plugin.id().data(), plugin.id().size());
lua_setfield(L, -2, "id");
set_function(L, "log", ApiFunctions::log);
set_function(L, "on", ApiFunctions::on);
lua_createtable(L, 0, 4);
set_function(L, "now", ApiFunctions::now);
set_function(L, "after", ApiFunctions::after);
set_function(L, "every", ApiFunctions::every);
set_function(L, "cancel", ApiFunctions::cancel);
readonly();
lua_setfield(L, -2, "time");
lua_createtable(L, 0, 1);
set_function(L, "current", ApiFunctions::level);
readonly();
lua_setfield(L, -2, "level");
lua_createtable(L, 0, 3);
set_function(L, "read", ApiFunctions::read);
set_function(L, "read_json", ApiFunctions::read_json);
set_function(L, "list", ApiFunctions::list);
readonly();
lua_setfield(L, -2, "package");
lua_createtable(L, 0, 2);
set_function(L, "encode", ApiFunctions::encode);
set_function(L, "decode", ApiFunctions::decode);
readonly();
lua_setfield(L, -2, "json");
lua_createtable(L, 0, 2);
set_function(L, "command", ApiFunctions::command);
lua_pushcfunction(L, ApiFunctions::console_print, "print");
lua_setfield(L, -2, "print");
readonly();
lua_setfield(L, -2, "console");
lua_createtable(L, 0, 4);
lua_pushcfunction(L, ApiFunctions::storage_get, "get");
lua_setfield(L, -2, "get");
lua_pushcfunction(L, ApiFunctions::storage_set, "set");
lua_setfield(L, -2, "set");
lua_pushcfunction(L, ApiFunctions::storage_add, "add");
lua_setfield(L, -2, "add");
lua_pushcfunction(L, ApiFunctions::storage_keys, "keys");
lua_setfield(L, -2, "keys");
readonly();
lua_setfield(L, -2, "storage");
readonly();
lua_setglobal(L, "reskate");
lua_pushcfunction(L, ApiFunctions::print, "print");
lua_setglobal(L, "print");
lua_pushcfunction(L, ApiFunctions::require, "require");
lua_setglobal(L, "require");
}
}