Это вторая часть туториала, в которой мы немного развязываем себе руки.
Вместо объяснения уже готового кода, формат статьи предполагает его последовательное написание вперемежку с выдачей порций теории. Когда закончим (скорее всего, третья часть будет последней), выложу ссылку на весь код в удобоваримом, готовом к надругательствам виде.
Разделяй и властвуй
Возможность запуска файлов со скриптами из самих скриптов.
В Lua это осуществляется двумя путями: через loadfile/dofile или require. Первые два живут в библиотеке base, последний — в package. Напомню, что ни одну из этих функций мы в песочницу не грузим, а родной require, так и вовсе со всей библиотекой игнорируем по соображениям безопасности. Но прежде чем делать свою реализацию, кратко пробежимся по ожидаемому от них поведению.
loadfile просто загружает указанный файл, компилирует его в байткод и возвращает как функцию, но не запускает на выполнение. Либо, в случае возникновения, возвращает ошибку (если быть совсем точным, то сразу два значения: nil вместо функции и ошибку):
-- script.lua
scriptLoaded = true
scriptLoaded = nil
local fn, err = loadfile("script.lua")
if not fn then
print(err)
else
print(scriptLoaded) -- > nil
fn()
print(scriptLoaded) -- > true
end
dofile же, и загружает (через тот же самый loadfile), и запускает. А возвращает результат запущенной функции. В случае же возникновения ошибки, не возвращает ничего и бросает Lua-ошибку (исключение, если хотите).
function dofile(filename)
local fn, err = loadfile(filename)
if not fn then
error(err, 2)
end
return fn()
end
Эта ошибка, если не будет отловлена, например, через тот же pcall, приведёт к остановке программы. Благо, отлов реализовать довольно просто:
-- script.lua
return "One Ring to rule them all..."
local ok, res = pcall(dofile, "script.lua")
if not ok then
print("error:", res) -- в res pcall поеместит сообщение об ошибке
end
print(res) -- > One Ring to rule them all...
Если же скрипт возвращает несколько значений, то нам нужно просто добавить количество переменных в соответствии с ожидаемым количеством результатов.
local ok, res1, res1, res3 = pcall(dofile, "script.lua")
Ошибка, в данном случае, будет помещена в res1.
И вот здесь нам нужно будет определиться — хотим мы отлавливать ошибки на стороне C++ или всё-таки Lua? Потому что от этого напрямую зависит семантика вызова — что именно будет сидеть в возвращаемом значении в случае ошибки, и ожидать ли Lua-коду её расшифровку (если мы обернём вызов в pcall).
Но есть один нюанс. Дело в том, что для того, чтобы pcall смог поймать ошибку, мы должны вызвать её при помощи C API'шных функций lua_error/luaL_error, которые, в свою очередь, используют механизм longjump для переброса на тот самый вызов pcall. При этом происходит перепрыгивание через C++ кадры стека, и деструкторы C++ объектов на этом пути не вызываются. RAII и lua_error/luaL_error — не самые хорошие соседи.
Поэтому в целях данной статьи предлагаю остановиться на упрощённом варианте — отлавливать на стороне C++ и вообще ничего не возвращать в случае ошибки, плюс добавим ещё одну реализацию — safe_dofile с семантикой вызова аналогичной вызову через pcall, то есть:
local ok, res1, res2, res3 = safe_dofile("script.lua")
if not ok then
print ("Error:", res1)
end
Так, с этими двумя, вроде, разобрались. Остался require.
А, вот, requre — это механизм Lua для загрузки модулей. Причём в качестве модулей могут выступать, помимо, собственно, скриптов .lua, файлы с уже скомпилированным Lua-байткодом (в данном аспекте, кстати, dofile от него тоже не отстаёт), и библиотеки: начиная от стандартных Lua-библиотек (о которых мы говорили выше), до обычных динамических C-библиотек (ну, те, которые .so/.dll/.dylib). И это всё прямо из коробки. А если ещё добавить свои загрузчики, то проглотит вообще любой формат: будь то JSON, архив или бинарный код. Вот уж где нашим юным натуралистам действительно было бы где разгуляться.
Кроме того, у require есть пара ключевых отличий от dofile, на которых придётся остановиться чуть подробнее, так как они важны для нашей реализации:
Для того чтобы загрузить модуль, нам не нужно знать, где именно тот сидит в файловой системе — достаточно просто указать имя модуля, и
requireсам полезет его искать. И даже больше:requireв принципе не сможет корректно обработать в качестве своего аргумента имя модуля с явным указанием пути к нему. А вот дляdofileпути нужно указывать явно:-
dofile("path/to/our/modules/rocket_science.lua")vs
require("rocket_science")Ищет он, конечно, не по всей файловой системе, а только в заранее заданных путях, но механизм защиты переменной, содержащей разрешённые пути отсутствует напрочь — это одна из причин, почему мы не используем родной
requireпредоставляемый Lua.На отсутствие расширения в имени файла во втором варианте внимание же тоже обратили, да? Это не ошибка — это ещё одна особенность, которую нужно учитывать.
requireне просто загружает файл, он задействует механизм кеширования уже подтянутых модулей и при повторном запросе просто подсовывает их из кеша. В нашем случае мы пойдём на упрощение и не будем реализовывать этот функционал, т.к. нам нужен хотрелоад (перезагрузка модулей в случае их изменения на диске), и мы допускаем, что модули могут иметь одинаковые имена, но жить в разных директориях — например, у разных модов могут быть свои реализации.
Итого в сухом остатке по require имеем:
Скрипты грузить может, но нет возможности явно указать путь к файлу — вычёркиваем,
dofileдля этого лучше подходит.Кеширование для нас не актуально — вычёркиваем.
А вот загрузку библиотек, пожалуй, оставим. Но ограничим только стандартными Lua-библиотеками — у нас как раз
Presets::Customпредполагает такую возможность.
Запуск сторонних скриптов же целиком и полностью возложим на dofile.
Path (2000) by Apocaliptica
Теперь нюансы, касающиеся самих путей.
Первое: dofile принимает как абсолютные, так и относительные пути. Причём, если с абсолютными всё понятно — ну это те, что C:\Windows\System32 и /opt/dwarffortress — т.е. полные и однозначные. То с относительными — которые ../../.ssh и ..\Downloads — сложнее, так как они интерпретируются не относительно директории текущего скрипта (в котором dofile вызывается), а относительно текущего рабочего каталога процесса.
Поэтому, если предполагается хоть какая-то иерархическая организация файлов скриптов, то нам придётся для каждого вызываемого скрипта указывать полный путь к нему (ну, не вручную в самом скрипте конечно, но определённую толику геморроя, связанного с подкапотными преобразованиями относительный -> абсолютный нам это добавит). Плюс, это позволит прикрыть лазейку с обходом ограничений путём подмены рабочего каталога самого процесса.
Следовательно, для песочниц нам нужно задать:
Список разрешённых путей, откуда позволено скрипты запускать.
Корневую директорию, от которой будут интерпретироваться относительные пути. Если же она не задана, то вообще их запрещаем и работаем только с абсолютными.
С теорией закончили, переходим к водным процедурам — начнём с добавления поддержки путей:
namespace fs = std::filesystem;
class LuaSandbox
{
public:
...
using Paths = std::vector<fs::path>;
// Дополняем конструктор:
//
// @root Рабочая директория - отсюда рассчитываются относительные пути
// скриптов. Если не задана, то разрешаем только абсолютные.
// @allowedPaths Список разрешённых путей, а вот здесь допускаем как
// относительные, так и абсолютные.
explicit LuaSandbox(LuaRuntime &runtime,
Presets preset,
const fs::path &root = {},
const Paths &allowedPaths = {})
: runtime(&runtime),
preset(preset)
{
setPathsForScripts(root, allowedPaths);
reset();
}
// Интерфейс для добавления пути к списку разрешённых. Сделаем его публичным,
// чтобы была возможность добавлять не только на этапе создания объекта
bool allowScriptPath(const fs::path &path);
...
private:
// А через это инициализируем пути при создании песочницы
void setPathsForScripts(const fs::path &root, const Paths &allowed);
// Преобразует текст в путь в т.ч. с учётом базового (для относительных)
auto toScriptPath(const std::string &fileName) const -> fs::path;
...
private:
// Храним как абсолютные, лексически нормализованные
fs::path scriptsRoot{}; // Базовый путь
Paths allowedScriptPaths{}; // Список разрешённых, преобразованных в абсолютные
...
};
void LuaSandbox::setPathsForScripts(const fs::path &root, const Paths &allowed)
{
scriptsRoot.clear();
if (!root.empty() && root.is_absolute()) {
scriptsRoot = fs_utils::normalize(root);
}
allowedScriptPaths.clear();
for (const auto &path : allowed) {
allowScriptPath(path);
}
}
bool LuaSandbox::allowScriptPath(const fs::path &path)
{
// Не добавляем относительные пути если базовый изначально не был задан
if (path.empty()
|| (scriptsRoot.empty() && path.is_relative())) {
return false;
}
// Абсолютные добавляем как есть,
// относительные преобразовываем относительно базового
const auto allow = path.is_relative() ? scriptsRoot / path : path;
allowedScriptPaths.push_back(fs_utils::normalize(allow));
return true;
}
auto LuaSandbox::toScriptPath(const std::string &fileName) const
-> fs::path
{
auto scriptPath = fs::path(fileName);
// Если путь относительный то преобразовываем его относительно базового.
// Если он задан. Если нет, то это не наша проблема - наверху разберутся
if (scriptPath.is_relative() && !scriptsRoot.empty()) {
scriptPath = scriptsRoot / scriptPath;
}
return scriptPath.lexically_normal();
}
fs_utils::normalize в представленном коде — это ещё один хелпер, на сей раз уже для работы с путями. Точнее, fs_utils — это неймспейс с несколькими такими утилитами. В силу того, что они лишь косвенно связаны с обсуждаемой темой, подробно останавливаться на этом не будем.
Но если, вдруг, совсем интересно.
namespace fs = std::filesystem;
// Опять же — изящно и непринуждённо объявляем синоним для
// "Любой контейнер с путями" /sarcasm off
template <typename T>
concept fsPaths =
std::ranges::range<T>
&& std::same_as<std::remove_cvref_t<std::ranges::range_value_t<T>>, fs::path>;
namespace fs_utils
{
inline auto normalize(const fs::path &path) -> fs::path
{
auto result = path.lexically_normal();
if (result.native().ends_with(fs::path::preferred_separator)) {
return result.parent_path();
}
return result;
}
// Проверяет, что path находится внутри root
inline bool startsWith(const fs::path &path, const fs::path &root)
{
if (root.empty()) {
return false;
}
// Да, здесь присуствует fs::absolute, который строит путь
// относительно рабочей дирректории *процесса* - ибо утилита
// универсальная и работать должна с обоими типами,
// но в нашем случае это не страшно т.к. мы сюда передаём только
// абсолютные пути.
const auto rootNorm = normalize(fs::absolute(root));
const auto pathNorm = normalize(fs::absolute(path));
const auto [rootEnd, _] = std::ranges::mismatch(rootNorm, pathNorm);
return rootEnd == rootNorm.end();
}
// Проверяет, что path находится внутри одного из roots
inline bool startsWith(const fs::path &path, const fsPaths auto &roots)
{
if (roots.empty()) {
return false;
}
for (const auto &root : roots) {
if (startsWith(path, root)) {
return true;
}
}
return false;
}
} // namespace fs_utilsТеперь у нас есть всё необходимое для реализации проверки путей при попытке запуска скрипта.
Собираем в кучу
Прежде чем начать пережёвывать файл со скриптом нам нужно проверить его:
На наличие.
На допустимость его пути.
И на его содержимое. Точнее, на то, что он не содержит уже скомпилированный байткод, т.к. он позволяет обойти ограничения песочницы.
auto LuaSandbox::checkIfAllowedToLoad(const fs::path &scriptFile) const
-> std::tuple<bool, std::string_view>
{
if (!fs::exists(scriptFile)) {
return {false, "Attempting to run a non-existent script"};
}
if (!isPathAllowed(scriptFile)) {
return {false, "Attempting to run a script outside the allowed path"};
}
if (lua::isBytecode(scriptFile)) {
return {false, "Attempting to run precompiled Lua bytecode"};
}
return {true, {}};
}
class LuaSandbox
{
...
private:
bool isPathAllowed(const fs::path &scriptFile) const;
auto checkIfAllowedToLoad(const fs::path &scriptFile) const
-> std::tuple<bool, std::string_view>;
...
};
С fs::exists всё понятно — функция стандартная, проверяет существование файла или самой директории.
С проверкой допустимости пути тоже всё довольно просто:
bool LuaSandbox::isPathAllowed(const fs::path &scriptFile) const
{
if (scriptFile.empty()) {
return false;
}
if (scriptFile.is_relative()) {
if (scriptsRoot.empty()) { // Проверяем, что можем преобразовать к абсолютному
return false;
}
// Важно: в fs_utils::startsWith мы должны передавать асолютный путь,
// иначе она там его автоматом интерпретирует относительно
// рабочей директории процесса
return fs_utils::startsWith(scriptsRoot / scriptFile, allowedScriptPaths);
}
return fs_utils::startsWith(scriptFile, allowedScriptPaths);
}
А вот с проверкой на байт-код всё не так прозаично. Хотя...
Файлы, содержащие скомпилированный байт-код, начинаются со специальной сигнатуры <esc>Lua, где <esc> - это 27 в десятичной, или 033 в восьмеричной системе счисления, в которой она и объявлена в lua.h:
#define LUA_SIGNATURE "\033Lua"
Поэтому просто проверяем первые 4 байта файла на соответствие ей.
namespace lua
{
bool isBytecode(const fs::path &file)
{
constexpr auto signature = std::string_view(LUA_SIGNATURE);
auto ifs = std::ifstream(file, std::ios::binary);
if (!ifs) {
return false;
}
auto header = std::array<char, signature.size()>{};
ifs.read(header.data(), header.size());
if (ifs.gcount() < static_cast<std::streamsize>(header.size())) {
return false;
}
return ranges::equal(header, signature);
}
} // namespace lua
loadfile
Напомню контракт вызова — стандартная (nil, error) идиома:
result, error = loadfile("filename.lua")
if not result then
-- тогда error содержит ошибку
end
В C++ у нас для таких случаев есть pair или tuple. Пускай будет tuple.
using ResultOrErrorMsg = std::tuple<sol::object, sol::object>;
auto LuaSandbox::loadfileReplace(sol::stack_object fileName)
-> ResultOrErrorMsg
{
// Введём просто чтобы сократить количество писанины
auto lua = sol::state_view(runtime->state.lua_state());
// Используется для формирования результата с ошибкой
auto makeError = [&](std::string_view errMsg) -> ResultOrErrorMsg {
return {sol::nil, sol::make_object(lua, errMsg)};
};
// На всякий случай
if (!fileName.is<std::string>()) {
return makeError("Bad argument #1 to 'loadfile' (string expected)");
}
// sol::stack_object -> fs::path
const auto filePath = toScriptPath(fileName.as<std::string>());
// Проверяем уже сам файл на допустимость
const auto &[isFileOk, fileErrMsg] = checkIfAllowedToLoad(filePath);
if (!isFileOk) {
return makeError(fileErrMsg);
}
// А это, собственно, родной Lua loadfile
auto loadResult = lua.load_file(filePath.string(), sol::load_mode::text);
if (!loadResult.valid()) {
sol::error err = loadResult;
return makeError(err.what());
}
// "Вытягиваем" из полученного loadResult чанк-функцию
auto chunk = sol::protected_function(loadResult);
sandbox.set_on(chunk); // и "опесочиваем" его подменяя окружение
// Напомню: sandbox здесь - это объект sol::environment
return { sol::make_object(lua, chunk), sol::nil };
}
safe_dofile
*Который с контрактом (ok, results... = safe_dofile())
А вот для возврата переменного количества значений в sol2 есть sol::variadic_results, который, фактически, представляет собой не что иное, как просто вектор объектов sol::object.
auto LuaSandbox::dofileSafe(sol::stack_object fileName)
-> sol::variadic_results
{
// Опять же, сокращаем количество писанины
auto lua = sol::state_view(runtime->state.lua_state());
auto result = sol::variadic_results {}; // Результат выполнения скрипта
// Лямбда для формирования "ошибочного" результата, содержащего текст ошибки.
auto makeError = [&](const std::string &msgError) {
result.push_back (sol::make_object(lua, false));
result.push_back(sol::make_object(lua, msgError));
return result;
};
// Загружаем файл скрипта.
// Все проверки пути и самого файла мы там уже реализовали.
auto [chunk, error] = loadfileReplace(fileName);
if (!chunk.valid()) {
const auto msgError = std::format(R"(Unable to load script "{}". Error: "{}")",
fileName.as<std::string>(),
error.as<std::string>());
return makeError(msgError);
}
// Т.к. на выходе из loadfile chank у нас обёрнут в sol::object, нам нужно
// явно указать, что это функция, прежде чем вызвать её на выполнение.
auto fn = chunk.as<sol::protected_function>();
// Запускаем и проверяем уже на наличие ошибки выполнения самого скрипта.
// sol::protected_function_result, возвращаемый fn(), даёт нам такую информацию.
auto scriptResult = fn();
if (!scriptResult.valid()) {
sol::error err = scriptResult;
const auto msgError = std::format(R"(Unable to execute script "{}". Error: "{}")",
fileName.as<std::string>(),
err.what());
return makeError(msgError);
}
// Пушим первый объект - статус выполнения
result.push_back(sol::make_object(lua, true));
// И вытягиваем все значения из результата выполнения запрошенного скрипта
for (auto &&value : scriptResult) {
result.push_back(value);
}
return result;
}
dofile
В документации на sol2 форграундом по бэкграунду написано, что предпочитаемый способ запуска скриптов — через sol::state::script() и sol::state::script_file() (ну или их safe - версии). Предпочитаемый до тех пор, пока вы явно не захотите странного (правда в терминах документации это звучало как: полного контроля над загрузкой и выполнением кода). Странного мы уже хотели — loadfileReplace и dofileSafe реализованы как раз через это. Обычный же dofile сделаем по заветам RTFM.
По большому счёту script_file() - это не что иное, как уже реализованная на стороне sol2 обёртка над loadfile, с последующим запуском скомпилированной им функции. А safe-версия просто добавляет контроль ошибок через pcall.
И тут вспоминаем, про уже имеющийся у нас runFile, который запускает запрошенный файл со скриптом.
auto LuaSandbox::runFile(const fs::path &scriptFile)
-> sol::protected_function_result
{
return runtime->state.safe_script_file(scriptFile, sandbox); // Прям по канону )
}
Да, пока не содержит вообще никаких проверок, но чуть доработать напильником и замена для dofile с ожидаемым поведением у нас в кармане.
Небольшое отступление (давно не было, правда? Но это нам, действительно, сейчас понадобится - придётся ещё немного потерпеть) — пара слов о sol::protected_function_result, который мы здесь возвращаем.
В sol2 есть два типа функций с довольно характерными названиями: sol::unsafe_function — дефолтный, и sol::protected_function.
Когда мы вызываем Lua-функцию через sol::protected_function, sol2 перехватывает любые рантайм ошибки Lua и, вместо того чтобы позволить программе аварийно завершиться или передать ошибку в виде C++-исключения (как бы он это сделал для sol::unsafefunction)_, оборачивает её в sol::protected_function_result и возвращает как результат выполнения функции. В случае же успешного завершения, в него заворачиваются все возвращаемые значения вызываемой функции (да, их может быть несколько). Фактически, это такой аналог вызова через pcall в Lua.
safe_script() и safe_script_file(), кстати, тоже его возвращают.
sol::state lua;
const auto script(R"(
function divide(a, b)
if b == 0 then
error("Are you kidding me?", 2)
end
return a / b
end
return divide
)");
auto divide = lua.safe_script(script); // sol::protected_function
if (!divide.valid()) {
return;
}
auto result = divide(42, 0); // sol::protected_function_result
if (!result.valid()) {
sol::error err = result;
std::cerr << err.what() << '\n'; // -> "Are you kidding me?"
// Обработка ошибки
return
}
float quotient = result;
// Или так:
auto alsoQuotient = result.get<float>();
А, вот, чтобы сформировать его вручную надо немного заморочиться:
В случае успешного выполнения функции поместить в стек — возвращаемый функцией Lua-объект, будь то значение, таблица, функция или
nil;Или же, в случае ошибки, вместо результата пушим в стек соответствующее ей сообщение;
И, наконец, создаём объект
sol::protected_function_resultс указанием статуса результата — валидный, который можно использовать дальше, или же — ошибка, которую нужно обработать. Здесь же указываем количество объектов, которое поместили в стек (да, их может быть несколько, но в нашем случае используем только один), эта информация в т.ч. нужна деструкторуsol::protected_function_resultдля того, чтобы подчистить стек за собой, но это уже нюансы реализацииsol2, не будем углубляться.
Звучит многословно, но в коде выглядит более чем лаконично:
namespace lua
{
auto makeFnCallResult(sol::state &lua,
const auto &object,
sol::call_status callStatus = sol::call_status::ok)
-> sol::protected_function_result
{
bool isResultValid = callStatus == sol::call_status::ok;
sol::stack::push(lua, object);
return sol::protected_function_result(lua,
-1,
isResultValid ? 1 : 0,
1,
callStatus);
}
} // namespace lua
Вот теперь мы можем дополнить runFile проверками на допустимость файла и возвращать ошибку, если их не проходим.
auto LuaSandbox::runFile(const fs::path &scriptFile)
-> sol::protected_function_result
{
// Лямбда для формирования "ошибочного" результата, содержащего текст ошибки.
auto error = [&](std::string_view msg) {
const auto errMsg = std::format("{}: {}", msg, scriptFile.string());
return lua::makeFnCallResult(runtime->state, errMsg, sol::call_status::file);
};
// Проверки на допустимость файла
if (const auto [isFileOk, errMsg] = checkIfAllowedToLoad(scriptFile); !isFileOk) {
return error(errMsg);
}
return runtime->state.safe_script_file(scriptFile, sandbox);
}
Осталось обернуть его для приёма аргументов напрямую из Lua скриптов (помним же про sol::stack_object?). Ну и ошибки обрабатываем на стороне C++, т.к. Lua даже не знает о существовании sol::protected_function_result, который для него любезно распатронивает sol2. И, собственно, всё — замена для dofile у нас готова:
auto LuaSandbox::dofileReplace(sol::stack_object fileName)
-> sol::protected_function_result
{
// Опять же, на всякий случай
if (!fileName.is<std::string>()) {
return {}; // ничего не возвращаем
}
// sol::stack_object -> fs::path
const auto filePath = toScriptPath(fileName.as<std::string>());
auto scriptResult = runFile(filePath);
if (!scriptResult.valid()) {
sol::error err = scriptResult;
// Обработка ошибок
// std::cerr << err.what() << '\n'
return {}; // ничего не возвращаем
}
return scriptResult;
}
require
С заменой для require всё существенно проще: если запрашиваемый аргумент — имя стандартной Lua-библиотеки, то пробуем её загрузить. Контроль того, что можно загружать и для какого из пресетов LuaSandbox::Presets, у нас уже реализован. Так что, в случае успеха, возвращаем таблицу с загруженной библиотекой, иначе nil — все ошибки обрабатываем на стороне C++.
auto LuaSandbox::requireReplace(sol::stack_object target)
-> sol::object
{
if (!target.is<std::string>()) {
return sol::nil;
}
const auto possibleLibName = target.as<std::string>();
const auto lib = lua::libByName(possibleLibName);
if (!lib) { // Проверяем есть ли такая бибилиотека
// std::format(R"(require("{}"): library not found.)", possibleLibName);
return sol::nil;
}
if (!require(*lib)) { // Пробуем её загрузить
// std::format(R"(require("{}"): library is forbidden.)", possibleLibName);
return sol::nil;
}
const auto libLookupName = lua::libLookupName(*lib);
return sandbox[libLookupName];
}
Наконец, объявляем:
class LuaSandbox
{
...
private:
auto dofileReplace(sol::stack_object fileName) -> sol::protected_function_result;
auto requireReplace(sol::stack_object target) -> sol::object;
auto toScriptPath(const std::string &fileName) const -> fs::path;
// Метод, которым будем регистрировать наши замены
void loadSafeExternalScriptFilesRoutine()
{
sandbox.set_function("dofile", &LuaSandbox::dofileReplace, this);
sandbox.set_function("require", &LuaSandbox::requireReplace, this);
}
...
};
И на этом с водными процедурами заканчиваем. Можно выдыхать.
Вот так — тихо и незаметно, на исходе второй части мы подобрались к тому, с чего начинаются все учебники )
Hello world!
¯\_(ツ)_/¯

Итак, print. В принципе, можно было бы и родной оставить, но раз есть возможность перенаправить его выхлоп куда-нибудь помимо stdout то почему бы ею не воспользоваться?
Для сохранения логики работы оригинального print — а он мало того, что сам корректно конвертирует числа, так ещё и для таблиц и функций, полученных в качестве аргументов, даст строки вида table: 0x12345 / function: 0x... — нам придётся задействовать стандартный tostring из sol::lib::base. Причём в саму песочницу его грузить не нужно — достаточно того, чтобы он присутствовал в Lua-стейте.
Ну и, естественно, добавим опцию изменения потока вывода.
void LuaSandbox::printReplace(sol::variadic_args args)
{
std::string result;
for (auto &&arg : args) {
result += lua::toString(arg);
result += " "; // родной print все аргументы разделяет пробелами
}
if (!result.empty()) {
result.pop_back(); // удаляем лишний пробел в конце
}
*printOutStrm << "[lua sandbox]:> " << result << "\n";
}
namespace lua
{
auto toString(const sol::object &obj) -> std::string
{
sol::state_view lua(obj.lua_state());
if (!lua["tostring"].valid()) {
return {};
}
return lua["tostring"](obj).get<std::string>();
}
} // namespace lua
Остаётся только доработать определение класса — добавляем в конструктор песочницы аргумент с потоком вывода, объявляем нашу замену для print и добавляем метод, осуществляющий его подмену.
class LuaSandbox
{
public:
...
explicit LuaSandbox(LuaRuntime &runtime,
Presets preset,
const fs::path &root = {},
const Paths &allowedPaths = {},
std::ostream &printOutStrm = std::cout) // По умолчанию
// оставляем `stdout`
: runtime(&runtime),
preset(preset),
printOutStrm(&printOutStrm)
{...}
...
private:
void printReplace(sol::variadic_args args);
void loadSafePrint()
{
// В Lua-стейт должна быть загружена библиотека base, чтобы tostring работал.
// Обращаю внимание - в сейт. В самой песочнице эта библиотека может быть
// вообще не загружена, но print работать будет.
runtime->require(sol::lib::base);
sandbox.set_function("print", &LuaSandbox::printReplace, this);
}
...
std::ostream *printOutStrm;
};
Ну что, в качестве промежуточного итога давайте теперь попробуем нашкодить?
«Ага, б...!» — сказали суровые сибирские лесорубы
namespace fs = std::filesystem;
const auto wrkDir = fs::current_path(); // Пускай будет рабочий каталог процесса
const auto allowedDirs = LuaSandbox::Paths{wrkDir / "scripts"};
auto lua = LuaRuntime {};
auto sandbox = LuaSandbox(lua,
LuaSandbox::Presets::Core, // Запрещаем вообще все либы
wrkDir, // Допустим, получили через аргументы командной строки
allowedDirs); // Разрешаем скрипты только отсюда
-- try_1.lua
-- ../try_1.lua
-- scripts/try_1.lua
-- print есть?
if print then
-- что, и работает?
print("Knock-knock-knok... ") -- > Knock-knock-knok...
else
return "не, не в этот раз"
end
-- ok, print есть, значит base загружена?
if not ipairs or not pcall then
-- А вот хрен там - не загружена
if require then -- а require есть?
-- есть! пробуем загрузить base
local res = require("base")
if not res then
-- литовский праздник
return "Обломайтэс"
end
end
end
return "bingo!"
// Пробуем выполнить скрипт
assert(fs::exist("script/try.lua") == false); // не существующий
auto result = sandbox.runFile("script/try.lua"); // но в пределах разрешённого пути
assert(result.valid() == false);
assert(fs::exist("try_1.lua") == false); // существующий
auto result = sandbox.runFile("try_1.lua"); // но вне "scripts"
assert(result.valid() == false);
assert(fs::exist("../try_1.lua") == false); // существующий
result = sandbox.runFile("../try_1.lua"); // но за пределами рабочего каталога
assert(result.valid() == false);
result = sandbox.runFile("scripts/try_1.lua");
assert(result.valid() == true); // ага, получилось
assert(result.get<std::string>() == "Обломайтэс"); // но библиотеку не может загрузить
// Ладно, а так?
sandbox = LuaSandbox(lua,
LuaSandbox::Presets::Custom, // Разрешаем ad-hoc подгрузку либ
wrkDir,
allowedDirs);
result = sandbox.runFile("scripts/try_1.lua");
assert(result.get<std::string>() == "bingo!"); // а так загрузил
// Ок, пробуем запрещёнку
-- scripts/try_2.lua
-- Чего мелочиться, давайте ФС пощупаем
-- Проверка на дурака - вдруг уже есть?
if io then
return "io.open('~/.ssh/config', 'r')"
end
-- ну да, конечно...
-- пытаемся загрузить
io = require("io")
if io then
return "io.open('~/.ssh/id_ed25519', 'r')"
end
-- ладно, тогда что-нибудь не сильно запрещённое
os = require("os")
-- проверям те, что гарантированно разрешены
if not os.time or not os.clock or not os.difftime then
return "неожиданно..."
end
-- о, загрузилась
if os.execute then
return "os.execute('echo rm -rf ~/')"
end
-- но не вся
return "хрен там"
result = sandbox.runFile("scripts/try_2.lua");
assert(result.get<std::string>() == "хрен там"); // опять мимо
// Ну что, остались только скрипты из Lua
-- downloads/pandoras_box.lua
-- scripts/pandoras_box.lua
require(table)
local box = {}
function box:open()
-- Тут интрига, ниже раскрою
end
function box.punishment(count)
-- интрига
end
function mobius()
-- интрига
end
function box:init()
self.open()
end
return box
-- scripts/script_loader.lua
-- пробуем несуществующий файл
local fn, err = loadfile("scripts/pandoras_chest.lua")
if fn then
return "It's a miracle"
end
-- существующий, но вне допустимого пути
fn, err = loadfile("downloads/pandoras_box.lua")
if fn then
return "Oops..."
end
-- ладно, хватит издеваться - загружаем нормальный
fn, err = loadfile("scripts/pandoras_box.lua")
if not fn then
return "Вот это поворот!"
end
-- отлично - загружается, для разнообразия запустим через safe_dofile
local ok, res = safe_dofile("scripts/pandoras_box.lua")
if not ok or (not res and not res.init) then
return "Oops!... I did it again"
end
harmless = res -- и помещаем в глобальную область видимости
return "Bomb has been planted"
// И проверяем на вшивость
result = sandbox.runFile("scripts/script_loader.lua");
assert(result.get<std::string>() == "Bomb has been planted");
// Поздравляю - Mischief managed
sandbox.run("harmless:init()"); // kaboom baby!
Потому что интрига выглядит так:
function box:open()
-- этот лангоньер сжирает всю память
for i = 1, 1000000 do
self.punishment(1000000)
end
-- а этим мы вешаем систему
self.mobius()
end
function box.punishment(count)
chalkboard = chalkboard or {}
for iter = 1, count do
table.insert(chalkboard, "I will not waste chalk" .. ", ")
end
end
function mobius()
while true do end
end
А вот что с этим делать — будем разбираться в следующей части. Не переключайтесь.