Это вторая часть туториала, в которой мы немного развязываем себе руки.

Вместо объяснения уже готового кода, формат статьи предполагает его последовательное написание вперемежку с выдачей порций теории. Когда закончим (скорее всего, третья часть будет последней), выложу ссылку на весь код в удобоваримом, готовом к надругательствам виде.

Разделяй и властвуй

Возможность запуска файлов со скриптами из самих скриптов.

В 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 имеем:

  1. Скрипты грузить может, но нет возможности явно указать путь к файлу — вычёркиваем, dofile для этого лучше подходит.

  2. Кеширование для нас не актуально — вычёркиваем.

  3. А вот загрузку библиотек, пожалуй, оставим. Но ограничим только стандартными 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>();

А, вот, чтобы сформировать его вручную надо немного заморочиться:

  1. В случае успешного выполнения функции поместить в стек — возвращаемый функцией Lua-объект, будь то значение, таблица, функция или nil;

  2. Или же, в случае ошибки, вместо результата пушим в стек соответствующее ей сообщение;

  3. И, наконец, создаём объект 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

А вот что с этим делать — будем разбираться в следующей части. Не переключайтесь.