Скриптовые языки уже давно и прочно заняли свою нишу в игрострое — они существенно упрощают описание игровой логики, уровней, ресурсов, диалогов, квестов, UI и чего только не. Что позволяет отдать эти задачи целиком и полностью в творческие руки гейм-/левел-/прочих-дизайнеров и других членов команды, которым не нужно обладать знаниями в том же C++. Разделение ответственности, ускорение разработки, облегчение моддинга возможность, по завершению разработки самого движка, вышвырнуть программистов на мороз и стричь купоны на бесконечных дополнениях — в общем, одни только плюсы. Да?

Да.

А ещё дыры в безопасности и уйма путей уронить стабильность.

В замечательном цикле статей "Game++" от @dalerank, а если быть конкретней, то в Game++. while (!game(over)) озвучена следующая мысль про скрипты в игровых движках, как раз на эту тему:

... на удивление, [скрипты — ] это про безопасность
Скрипты обычно запускаются в изолированной среде. Это значит, что если моддер написал что-то странное — он сломает только свою миссию, а не всю игру. Можно ограничить доступ скриптам: дать им возможность работать только с теми объектами, которые тебе нужны. Настроить лимиты, интерпретировать исключения, и в крайнем случае — просто не запускать подозрительное. В плюсах такой гибкости не получишь. Там любой кривой плагин — это потенциальный краш.

То есть лечится.

Но, блин, КАК?

К сожалению, ответ на этот вопрос выходил далеко за рамки статьи, поэтому придётся разбираться самостоятельно.

Постановка задачи

Для начала накинем немного контекста:

  • Во-первых, в качестве C++ хоста у нас выступает игровой движок, где скриптовый язык нужен для конфигов, логики, модов и вот этого вот всего.

  • Во-вторых, нужна возможность запуска сторонних, написанных не нами (читай: непроверенных и потенциально небезопасных) скриптов.

  • В-третьих, исходя из выше обозначенного, очень желательно свести к минимуму возможности нашкодить со стороны особенно любознательных пользователей, очень творческих мододелов и просто мамкиных кулхацкеров.

  • В-четвёртых, берём как данность, что автор этих строк от природы откровенно ленив, и поэтому по максимуму стараемся использовать уже готовые решения.

Опустим рассуждения о достоинствах и недостатках Lua в качестве скриптового языка, а также обзор возможных вариантов его подключения к нашему приложению — для этого есть уйма статей, написанных такими корифеями, что я даже смысла не вижу залезать на эту поляну. Просто, возьмём как данность, что выбор в итоге остановили на:

  • Lua 5.1/LuaJIT: Ибо `LuaJIT покрывает все остальные версии по производительности, даже с отключённым JIT (а у нас же геймдев). Но, к сожалению, она в принципе остановилась на этапе Lua 5.1 (с некоторыми оговорками), поэтому ориентироваться будем на синтаксис и набор библиотек, актуальный на тот момент. Тем не менее жёстко к LuaJIT-специфичным нюансам не привязываемся и стараемся все решения делать максимально универсальными, ну или требующими минимальных доработок.

  • Для подключения используем sol2 — невероятно вкусная библиотека, которая просто до неприличия упрощает взаимодействие с Lua. Опять же, без ущерба скорости (ну геймдев же, ну);

  • Ну и C++20. Просто потому, что есть такая возможность. Впрочем, всё, описанное ниже, вполне реализуемо и на более взрослых версиях стандарта.

По пути наименьшего сопротивления

Для выполнения Lua-кода из нашего приложения необходимо создать для него окружение — фактически виртуальную машину, содержащую всё необходимое для запуска скриптов: стек, глобальные переменные, таблицы и функции, а также аллокатор и сборщик мусора. В терминологии Lua это называется state (состояние).

Создание всего этого — задача, конечно, довольно нетривиальная.

И, в нашем случае, решается через...
// Добавление аж двух строк )
#include <sol/sol.hpp>
sol::state lua;

Собственно, уже на этом этапе в нашем распоряжении синтаксис и базовая семантика, циклы и операторы ветвления, логика и арифметика, таблицы и возможность определять свои функции.

Чего с головой хватит для описания большей части данных, которые нам могут понадобиться: юниты, инвентарь, диалоги, карты, описание уровней, UI, графика, звуки и чего только не.

-- Lua
-- config_screen.lua

local screen = {
    width = 1280,
    height = 720,
    presets = {
        ["HD"]  = {1280, 720},
        ["FHD"] = {1920, 1080},
        ["4K"]  = {3840, 2160},
        default = "HD"
    }
}
function getScreenPreset(preset)
    local presets = screen.presets
    return presets[preset] or presets[presets.default]
end

return screen
// Cpp
auto screenCfg = lua.script_file("config_screen.lua");

// доступ к элементам Lua-таблицы по ключу
int width = screenCfg["width"];
int height = screenCfg["height"];

std::cout << std::format("Configured screen resolution: {}x{}\n", width, height);
// --> Configured screen resolution: 1280x720

// можем вызывать функции объявленные в Lua
auto preset = lua["getScreenPreset"]("FHD");

// Доступ по индексу
width = preset[1]; // И да, индексация в Lua начинается c 1
height = preset[2];

std::cout << std::format("FHD resolution: {}x{}\n", width, height);
// --> FHD resolution: 1920x1080

А реализовав недостающий функционал на стороне C++, и пробросив его в Lua, можно уже замахиваться на игровую логику.

// Cpp
namespace math
{
    int add(int a, int b) { return a + b; };
}

auto api = lua.create_table();

api["add"] = math::add;
api["sub"] = [](int a, int b) { return a - b; };

lua["engineAPI"] = api;

lua.script_file("script.lua");

int a = lua["a"];
std::cout << std::format("a:{}, b:{}\n", a, lua["b"].as<int>());
// --> a:30, b:-10
-- Lua
-- script.lua

a = engineAPI.add(10, 20)
b = engineAPI.sub(10, 20)

И на этом можно было бы уже заканчивать.

Но.

У нас уже немало доступного функционала, а вот возможностей чувствительно нашкодить в скриптах ещё не очень (Кстати, какие варианты приходят в голову? В комментах писать не надо — а то подглянут ещё). Я бы всё-таки расширил пространство для манёвра любителям, скажем так, нестандартных решений — ну так, чисто ради подогрева интереса. Это, во-первых... А если серьёзно, у нас напрочь отсутствует возможность вызывать сторонние скрипты из самих скриптов, а это сразу — здравствуй потеря модульности и архитектур��ой гибкости (и это для игрового-то движка), грабли с условной динамической загрузкой, повышенная нагрузка на API движка (ну ладно, ладно — читать как "на разработчиков этого самого API").

Во-вторых, у нас, в самом начале, в исходных данных была обозначена природная лень. Следовательно, рожать велосипед самостоятельно повторяя функционал стандартных библиотек — ну серьёзно?

Ну а в-третьих, это было бы слишком просто, и даже начинать данную заметку не имело бы смысла. Но если посмотреть на ползунок прокрутки справа, то текста там ещё порядком. Поэтому придётся продолжать.

Тишина должна быть в библиотеке (c)

Здесь придётся чуть подробнее остановиться на том, что, собственно, представляет из себя загрузка библиотек в Lua.

Но начнем мы, как ни странно, с таблиц.

*Можно смело пропустить, если в курсе внутренней кухни Lua.

В Lua таблицы — это единый и универсальный механизм данных и структурирования, на котором фактически строится вообще весь язык. Они могут хранить значения любого типа: числа, строки, другие таблицы и функции. И не просто могут — вообще все переменные, функции, библиотеки и модули являются именно элементами таблиц.

Тип у Lua таблиц является ссылочным, т.е. когда мы присваиваем переменной или передаём в функцию какую-нибудь таблицу, копирования не происходит — присваивается и передаётся только ссылка. Если такая переменная выходит из области видимости или ей присваивается nil — ссылка обнуляется. А когда ссылок на нашу таблицу вообще не остаётся, она с чистой совестью выпиливается сборщиком мусора. Очевидно, что у нас должна быть какая-то главная таблица, не удаляемая сборщиком и в которой мы сможем создавать ссылки на новые.

И вот тут создатели языка подошли с фантазией. Они сделали её неявной, т.е. она есть, и вы можете даже обратиться к ней по имени, но это совсем не обязательно. Вы можете спокойно писать на Lua, даже не подозревая о её существовании. Конечно, есть несколько сценариев, когда явное обращение таки потребуется, но это уже для тех, кто действительно знает, что делает.

Ещё раз: основная точка входа в Lua — скрыта от пользователя, даёт работать с языком, даже не зная о месте её нахождения, а явное обращение к ней в умелых руках позволяет делать нетривиальные вещи.

Есть идеи для названия?

Её величество _G. Это переменная, содержащая ссылку на предопределённую таблицу глобального окружения. И абсолютно все глобальные переменные и функции являются полями этой таблицы.

-- Lua
x = 42
print(x)       --> 42
print(_G.x)    --> 42
_G.print(_G.x) --> 42

И именно в _G загружаются библиотеки, которые представляют из себя не что иное, как просто таблицы с функциями (упростил, да). Создаётся глобальная переменная, и в неё кладётся ссылка на соответствующую таблицу. Всё.

// Cpp
sol::state lua;

// Собственно, загрузка библиотек
lua.open_libraries(sol::lib::base, sol::lib::math);

lua.script(R"(
    print("#1 " .. math.max(10, 42, -1))
    print("#2 " .. _G.math.max(10, 42, -1))
)");
// --> #1 42
// --> #2 42

За редким исключением, правда — например, функции из base кладутся прямо в G — "корень" глобальной области видимости (тот же print, а не base.print из примера выше), или функция require, которую package тоже закидывает прямо в G, в отличие от всех остальных своих функций.

Дальше, в Lua нет механизма частичной загрузки библиотек. Первое, что приходит на ум:

// Cpp
lua.open_libraries(sol::lib::os);

// просто обнулить опасные функции
os["execute"] = sol::nil
os["remove"] = sol::nil
...

Но есть более гибкий вариант — механизм подмены таблицы глобального окружения, который позволяет запускать код в окружении, изолированном от основного Lua-стейта, т.е. в песочнице. В sol2 это реализуется через sol::environment.

// Cpp
lua::state lua;
lua::environment sandox(lua, sol::create);
sandbox["_G"] = sandbox;

lua.script_file("script.lua", sandbox);

И вот в неё-то мы уже можем спокойно пробросить только те части библиотек, которые посчитаем нужными.

// Cpp
lua::state lua;
lua::environment sandox(lua, sol::create);
sandbox["_G"] = sandbox;

const auto checkWhere = R"(
    if print then
        print (whereAmI)
    else
        houston()
    end
)";

auto houston = []() {
    std::cout << "Houston, we have a problem.\n";
};

lua["whereAmI"] = "In a Lua state";
lua["houston"] = houston;

sandbox["whereAmI"] = "In a sandbox";
sandbox["houston"] = houston;

lua.script(checkWhere); // --> Houston, we have a problem.

// Теперь загружаем библиотеку
lua.open_libraries(sol::lib::base);

lua.script(checkWhere); // --> In a Lua state

// *Явно указываем окружение, в котором нужно выполнить
lua.script(checkWhere, sandbox);  // --> Houston, we have a problem.

sandbox["print"] = lua["print"];
lua.script(checkWhere, sandbox); // --> In a sandbox

Кстати, тут есть один нюанс. Дело в том, что вот эта запись в Lua:

sandbox["libname"] = lua["libname"];

это — не копирование, а ссылка на объект. Поэтому, даже если мы хотим разрешить в песочнице библиотеку целиком, то всё равно придётся переносить её содержимое поэлементно, а не через проброс всей библиотечной таблицы.

  • Во-первых, библиотека может содержать какие-либо неявные, потенциально небезопасные поля, а Lua позволяет обойти их все простым перебором, даже не зная имён. Да, _G это, кстати, тоже касается.

  • Во-вторых, это как раз одна из тех самых возможностей нашкодить: доступ к корневой таблице библиотеки позволяет подменять её функции. В случае же поэлементного проброса подмена, конечно, тоже возможна, но это затронет только песочницу — испортится только ссылка на элемент, оставив саму библиотеку невредимой.

Использование sol::environment открывает нам ещё несколько возможностей: например, на одном Lua-стейте делать сразу несколько независимых песочниц — хоть по отдельной на каждый запускаемый скрипт. Что существенно облегчает задачу по реализации обмена данными между песочницами. Или при сбросе песочницы (например, перезапуск миссии) не нужно подгружать все библиотеки заново — мы просто заменяем таблицу-окружение на новую и копируем в неё разрешённые элементы. Ну и наконец, в случае необходимости, для доверенных скриптов в нашем распоряжении оказывается ещё и полнофункциональная версия Lua (сам Lua-стейт). Последнее использовать с оговорками и осторожностью, но тем не менее, возможность такая есть.

Теперь, собственно, библиотеки. Кратко пробежимся по тому, что же они нам предлагают — а то, может, действительно не стоит заморачиваться?

Библиотека

Назначение

Функционал / Описание

base

Базовые возможности языка

Основные конструкции Lua: работа с типами, проверка и преобразование значений, обработка ошибок, итерация по таблицам, выполнение кода из строк или файлов и базовые средства метапрограммирования.

package

Система модулей

Организация и загрузка Lua- и C-модулей, подключение библиотек.

string

Работа со строками

Изменение, поиск и шаблонное сопоставление строк, конкатенация, форматирование, преобразование регистра.

table

Манипуляции с таблицами

Работа с таблицами: сортировка, объединение, вставка, удаление, копирование, преобразование таблиц в строки и обратно.

math

Математика и случайность

Арифметические, тригонометрические и логарифмические функции, округление, генерация случайных чисел, операции с целыми.

io

Ввод-вывод

Работа с файлами и стандартными потоками: открытие, чтение, запись, построчная обработка, управление буферами.

os

Доступ к функциям ОС

Работа со временем, датой, окружением, файлами и системными командами. Позволяет взаимодействовать с внешней средой.

coroutine

Кооперативная многозадачность

Создание и управление корутинами: приостановка и возобновление выполнения, реализация последовательных сценариев.

debug

Отладка

Доступ к стеку вызовов, локальным переменным и функциям. Используется для профайлинга и инструментов разработки.

Из полезных есть ещё utf8 и bit32. Первая, как ни странно, для работы с кодировкой UTF8, и появилась она начиная с версии 5.3. Вторая — реализация битовых операций, но она была только в ветке 5.2, в 5.3 битовые операции уже напрямую в язык завели и надобность в данной библиотеке отпала. И да, битовых операций в 5.1 не было. Совсем.

Даже беглого просмотра достаточно, чтобы понять — брать надо. Но с осторожностью: в таблице в описании жирным выделен небезопасный функционал. Вырисовывается вполне себе чёткая картина — для части библиотек содержимое придётся загружать выборочно, а для некоторых — даже их название вслух произносить небезопасно. Поэтому последние просто игнорируем, но если уж совсем приспичит — их функционал придётся реализовывать самостоятельно на стороне движка (тот же доступ к ф��йловой системе, можно ограничить только на чтение, и только в пределах разрешённых путей).

Если же копнуть глубже, то в итоге у нас вырисовывается следующая картина:

Base — мастхэв, но:

  • Функционал загрузки и запуска кода на выполнение (все эти load, loadstring, loadfile, dofile) придётся взять на себя и реализовать безопасные замены самостоятельно. Этим займёмся позже.

  • Кроме того, запрещаем всё, что даёт доступ к метатаблицам (setmetatable, getmetatable) и позволяет их обходить (rawequal, rawget, rawset). Да, метатаблицы сами по себе могут использоваться как мощный инструмент реализации ограничений для небезопасных функций, но, к сожалению, в Lua нет механизма их защиты.

  • Не даём пользователю вручную вызвать сборщик мусора через collectgarbage.

  • Блокируем возможность читать и менять окружение (getfenv, setfenv).

  • И напоследок, print — заменим на свой вариант, чтобы можно было перенаправить вывод в поток, отличный от stdout.

package — небезопасна полностью. Единственное, стоит отдельно упомянуть require, которая является сильно продвинутой версией dofile из base, с поиском запрошенного файла в разрешённых путях и контролем повторной загрузки. Есть смысл тоже заменить на нашу безопасную реализацию dofile, добавив к ней возможность запроса на загрузку стандартных библиотек из самих скриптов.

string — однозначно забираем, за исключением потенциально небезопасной dump, которая позволяет получить байткод любой доступной функций.

table — целиком безопасна.

math — случайную генерацию (random, randomseed) лучше оставить на стороне хоста (для мультиплеера она обычно должна быть синхронизирована для всех клиентов), а остальное лишним точно не будет.

io — действительно, почему бы не дать сомнительным скриптам доступ к файловой системе?

os — оставляем только функции для доступа ко времени: clock, difftime и time.

coroutine — почему бы и да! Пусть будет.

debug — вообще без вариантов, здесь я бы её даже палкой трогать не стал.

utf8 и bit32 тоже безопасны целиком, и можно добавлять, но с оглядкой на используемую версию Lua, т.к. они не во всех представлены.

Поехали... (c)

Так, на данном этапе, похоже, что у нас уже наб��алась критическая масса информации, достаточная, чтобы начинать реализацию. Но, чтобы нас не разорвало в творческом порыве, пожалуй, стоит сразу обозначить основные моменты, которых будем придерживаться.

  1. Песочницы реализуем через механизм подмены глобальной таблицы-окружения (sol::environment).

  2. На одном Lua-стейте может быть несколько песочниц одновременно.

  3. Если уж параноить, то до конца — песочницы могут обладать разным уровнем доверия, поэтому необходимо иметь возможность ограничения списка доступных для них библиотек. Возможность запроса библиотек из самих скриптов, вообще только самым доверенным песочницам дадим, для остальных же — загрузка по разрешённым спискам только в момент создания самой песочницы.

  4. Нужен механизм сброса песочниц после использования до их изначального состояния.

  5. Библиотеки грузятся в Lua-стейт целиком, а в песочницы из них пробрасываются только безопасные функции. Функции же, которые нельзя, но очень хочется использовать — заменяем своими безопасными реализациями.

  6. Должна быть возможность запуска файлов со скриптами из самих скриптов. Здесь ещё придётся учитывать ограничения по доступным путям.

В первую очередь, чтобы можно было вести учёт уже загруженных библиотек и не дёргать open_libraries() каждый раз при создании новой песочницы, сделаем обёртку для sol::state.

class LuaRuntime : public sol::state
{
public:
    sol::state state;

    LuaRuntime() = default;
    ~LuaRuntime() = default;

    // Запрещаем копирование и перенос
    LuaRuntime(const LuaRuntime &) = delete;
    LuaRuntime(LuaRuntime &&) = delete;
    LuaRuntime &operator=(const LuaRuntime &) = delete;
    LuaRuntime &operator=(LuaRuntime &&) = delete;

    // Через это будем сообщать какие библиотеки нам понадобятся в песочнице,
    // и что их было бы неплохо загрузить в Lua-стейт
    void require(sol::lib lib)
    {
        if (!loadedLibs.contains(lib)) {
            state.open_libraries(lib);
            loadedLibs.insert(lib);
        }
    }

private:
    std::set<sol::lib> loadedLibs; // Список уже загруженных библиотек.
};

Ну и сама песочница:

class LuaSandbox
{
public:
    explicit LuaSandbox(LuaRuntime &runtime)
        : runtime(&runtime)
    {
        reset();
    }
    ~LuaSandbox() = default;

    // Аналогично — явно запрещаем копирование
    LuaSandbox(const LuaSandbox &) = delete;
    LuaSandbox &operator=(const LuaSandbox &) = delete;

    // Но перенос оставим, чтобы наши песочницы можно было в контейнерах хранить
    LuaSandbox(LuaSandbox &&) = default;
    LuaSandbox &operator=(LuaSandbox &&) = default;

    // Перегружаем [], чтобы снаружи был прозрачный доступ к элементам песочницы
    auto operator[](auto &&key) noexcept
    {
        return sandbox[std::forward<decltype(key)>(key)];
    }

    // Сброс через инициализацию новой песочницы
    // Старую сборщик мусора потом сам грохнет, ну или сразу, если явно попросим
    void reset(bool doCollectGarbage = false)
    {
        sandbox = sol::environment(runtime->state, sol::create);
        sandbox["_G"] = sandbox;

        if (doCollectGarbage) {
            runtime->state.collect_garbage();
        }
    }
    // Запуск скрипта на выполнение в песочнице
    auto run(std::string_view script)
        -> sol::protected_function_result
    {
        // Используем именно safe-версию, чтобы sol2 не выбрасывал нам исключения
        return runtime->state.safe_script(script, sandbox);
    }
    // И файла со скриптом, соответственно. Пока без контроля путей
    auto runFile(const std::filesystem::path &scriptFile)
        -> sol::protected_function_result
    {
        return runtime->state.safe_script_file(scriptFile, sandbox);
    }

private:
    LuaRuntime *runtime {nullptr};
    sol::environment sandbox;
};

О��ратите внимание: вызов скриптов на выполнение в run/runFile осуществляется через safe-версии script и script_file. Это сделано специально, чтобы sol2 нам не выбрасывал исключения в случае ошибки. Эти версии на выходе генерируют объект sol::protected_function_result, который сохраняет информацию об ошибках, что позволяет обрабатывать их вручную. Про него ниже поговорим ещё.

И вот теперь, наконец-то, можно переходить к библиотекам.

Мы уже выяснили, что содержимое библиотек грузить придётся выборочно. Насколько выборочно — тоже уже определили. Остаётся только задать соответствующие правила для каждой из библиотек.

class LuaSandbox
{
    ...
private:
    using LibNames = std::vector<std::string_view>;

    struct LibSymbolsRules
    {
        LibNames allowed{};                     // Белый список
        LibNames restricted{};                  // Чёрный
        bool allowedAllExceptRestricted{false}; // Определяет какой из списков 
                                                // используется
    };
    using LibsSandboxingRulesMap = std::map<sol::lib, LibSymbolsRules>;

    static const LibsSandboxingRulesMap libsSandboxingRules;
    ...
};

const LuaSandbox::LibsSandboxingRulesMap
LuaSandbox::libsSandboxingRules{
    {sol::lib::base,
        {.allowed = {"assert", "error", "ipairs", "next", "pairs",
                     "pcall", "select", "tonumber", "tostring",
                     "type", "unpack", "_VERSION", "xpcall"}}},
    {sol::lib::coroutine,
        {.allowedAllExceptRestricted = true}},
    {sol::lib::math,
        {.allowedAllExceptRestricted = true,
         .restricted = {"random", "randomseed"}}},
    {sol::lib::os,
        {.allowed = {"clock", "difftime", "time"}}},
    {sol::lib::string,
        {.allowedAllExceptRestricted = true,
         .restricted = {"dump"}}},
    {sol::lib::table,
        {.allowedAllExceptRestricted = true}}
};
Так, здесь будет вынужденное отступление от основной темы. А именно, по поводу применения в геймдеве контейнеров, активно использующих динамическую аллокацию памяти.

Ведь все эти std::vector, std::map и std::set чуть выше по тексту — это как раз они. А для последних двух ещё и локальность данных напрочь отсутствует — там под каждый элемент память выделяется отдельно и, следовательно, располагаться в адресном пространстве они могут где только не.

Да, конкретно в данной ситуации, можно абсолютно пренебречь оказываемым ими влиянием на производительность или фрагментацию памяти. Оно ничтожно, просто в силу их размера и того, что используются, по большей части, только в момент инициализации. Но для чистоты: set так и просится заменить его на какой-нибудь bit_set, а map и vector, в которых у нас сейчас сидят правила и список имён таблиц, недоумевают — почему они здесь отдуваются за что-нибудь constexpr-производное от того же std::array, например?

Просто, с точки зрения наглядности и читабельности, так лучше, а оптимизацию оставим на потом, когда она реально понадобится... Кто сказал Технический долг? Не понимаю, о чём вы.

На этом с правилами закончили. Теперь, в соответствии с ними, нужно загрузить сами библиотеки.

Так как мы оперируем библиотеками как sol::lib, то нам понадобится хелпер для получения реального Lua-имени:

// Заведём отдельный неймспейс для всякого рода вспомогательных функций.
namespace lua
{
    namespace details
    {
        struct LibName
        {
            sol::lib lib;
            std::string_view name;
        };
        constexpr auto libsNames = std::to_array({
            {sol::lib::base,      "base"},
            {sol::lib::bit32,     "bit32"},     // Lua 5.2 only
            {sol::lib::coroutine, "coroutine"},
            {sol::lib::debug,     "debug"},
            {sol::lib::ffi,       "ffi"},       // LuaJIT only
            {sol::lib::io,        "io"},
            {sol::lib::jit,       "jit"},       // LuaJIT only
            {sol::lib::math,      "math"},
            {sol::lib::os,        "os"},
            {sol::lib::package,   "package"},
            {sol::lib::string,    "string"},
            {sol::lib::table,     "table"},
            {sol::lib::utf8,      "utf8"}       // Lua 5.3+
        });
    } // namespace details

    // Собственно сам хелпер.
    constexpr auto libName(sol::lib lib) noexcept
        -> std::optional<std::string_view>
    {
        auto findLib = [lib](auto &lookup) -> bool {
            return lookup.lib == lib;
        };

        const auto &libs = lua::details::libsNames;
        if (auto it = ranges::find_if(libs, findLib); it != libs.end()) {
            return it->name;
        }
        return std::nullopt;
    }

    // Ну и чтобы два раза не вставать, сразу добавим обратный.
    constexpr auto libByName(std::string_view libName) noexcept
        -> std::optional<sol::lib>
    {
        auto findLibName = [libName](auto &lookup) -> bool { 
            return lookup.name == libName;
        };

        const auto &libs = lua::details::libsNames;
        if (auto it = ranges::find_if(libs, findLibName);
            it != libs.end()) {
            return it->lib;
        }
        return std::nullopt;
    }
} // namespace lua

Ну и наконец, сам загрузчик:

class LuaSandbox
{
    ...
private:
    void copyLibFromState(sol::lib lib, const LibSymbolsRules &rules);
    ...
};

void LuaSandbox::copyLibFromState(sol::lib lib, const LibSymbolsRules &rules)
{
    // Определяемся с именем таблицы, где обитает запрошенная библиотека.
    const auto libLookupName = lua::libLookupName(lib);
    if (libLookupName.empty()) {
        return;
    }
    // Копировать будем отсюда,
    const sol::table src = runtime->state[libLookupName];

    // проверив, на всякий случай, что таблица таки существует.
    if (!src.valid()) {
        return;
    }

    // Функции из 'base' заливаются прямо в '_G', который у нас уже есть,
    // для остальных же библиотек придётся создать свои таблицы для копирования.
    if (lib != sol::lib::base) {
        sandbox[libLookupName] = sol::table(runtime->state, sol::create);
    }
    // Сюда.
    sol::table dst = sandbox[libLookupName];

    // Ну и, собственно, то, к чему мы так долго и тернисто шли — заливаем в песочницу,
    if (rules.allowedAllExceptRestricted) {
        // копируя вообще всё содержимое поэлементно,
        for (const auto &[name, object] : src) {
            dst[name] = object;
        }
        // и удаляя запрещёнку.
        for (const auto &name : rules.restricted) {
            dst[name] = sol::nil;
        }
    } else { // Ну или, в случае белого списка,
        // просто грузим только разрешённое.
        for (const auto &name : rules.allowed) {
            dst[name] = src[name];
        }
    }
}

// И чуть не забытый хелпер
namespace lua
{
    // для определения имени таблицы — местоположения библиотеки внутри Lua-стейта.
    constexpr auto libLookupName(sol::lib lib) -> std::string_view
    {
        // Функции из `base` сидят прямо в "корне" — '_G',
        // в отличие от остальных библиотек.
        return (lib == sol::lib::base) ? "_G" : lua::libName(lib).value_or("");
    }
}  // namespace lua

И завершаем картину с библиотеками последними штрихами — интерфейс для их загрузки:

// С++ — очень элегантный и лаконичный язык, вы только посмотрите 
// как изящно и непринуждённо он позволяет выразить синоним для 
// "Какой-нибудь итерируемый контейнер с sol::lib внутри"
template <typename T>
concept SolLibContainer =
    std::ranges::range<T>
    && std::same_as<std::ranges::range_value_t<T>, sol::lib>;

// Благодаря чему, мы сможем почти из любого контейнера
// грузить библиотеки сразу пачкой.
void LuaSandbox::loadLibs(const SolLibContainer auto &libs)
{
    for (const auto &lib : libs) {
        loadLib(lib);
    }
}

// Ну или по одной.
bool LuaSandbox::loadLib(sol::lib lib)
{
    const auto rules = checkRulesFor(lib); // Для неё правила есть вообще?
    if (!rules) {
        return false;
    }
    // Запрос на загрузку библиотеки в Lua-стейт.
    // Нам же нужно её откуда-то в песочницу подтягивать
    runtime->require(lib);

    copyLibFromState(lib, *rules);
    loadedLibs.insert(lib); // Учёт загруженных библиотек

    return true;
}

// Просто проверка на наличие правил для конкретной библиотеки
auto LuaSandbox::checkRulesFor(sol::lib lib) const noexcept
    -> opt_cref<LibSymbolsRules>
{
    if (const auto it = libsSandboxingRules.find(lib);
        it =! libsSandboxingRules.end()) {
        return it->second;
    }
    return std::nullopt;
}

class LuaSandbox
{
    ...
public:
    void loadLibs(const SolLibContainer auto &libs);
    bool loadLib(sol::lib lib);

private:
    auto LuaSandbox::checkRulesFor(sol::lib lib) const noexcept
        -> opt_cref<LibSymbolsRules>

    std::set<sol::lib> loadedLibs;
    ...
};

Здесь может несколько смутить возвращаемый тип opt_cref<LibSymbolsRules> для checkRulesFor, но это просто константный std::optional для &ссылок, который в 20 стандарт C++ ещё не завезли.

Реализация, если интересно.
template <typename T>
class optional_ref
{
public:
    optional_ref() = default;
    optional_ref(std::nullopt_t) : ref(std::nullopt) {}
    optional_ref(T &value) : ref(value) {}

    explicit operator bool() const noexcept { return has_value(); }

    T &operator*() noexcept { return ref->get(); }
    const T &operator*() const noexcept { return ref->get(); }

    T *operator->() noexcept { return std::addressof(**this); }
    const T *operator->() const noexcept { return std::addressof(**this); }

    bool operator==(std::nullopt_t) const noexcept { return !has_value(); }
    bool operator!=(std::nullopt_t) const noexcept { return has_value(); }

    bool has_value() const noexcept { return ref.has_value(); }

    void reset() noexcept { ref.reset(); }

private:
    std::optional<std::reference_wrapper<T>> ref;
};

template <typename T>
using opt_ref = optional_ref<T>;

template <typename T>
using opt_cref = optional_ref<const T>;

Все животные равны, но некоторые равнее.

Вспоминаем про нашу паранойю и позволим создавать песочницы только по заранее заданным шаблонам, ограничивающим список загружаемых библиотек.

Отдельно выделим шаблон Custom, для которого, мало того, что весь список библиотек доступен, так ещё и позволяем загружать их по мере необходимости, а не в момент создания песочницы.

class LuaSandbox
{
public:
    enum class Presets { Core, Minimal, Complete, Custom };

    // Чуть доработаем наш конструктор
    // Чтобы без указания шаблона нельзя было создать песочницу
    explicit LuaSandbox(LuaRuntime &state, Presets preset)
        : lua(state),
          preset(preset)
    {...}

    // Ad-hoc загрузка библиотек для его величества Presets::Custom
    bool LuaSandbox::require(sol::lib lib)
    {
        if (preset == Presets::Custom) {
            return loadLib(lib);
        }
        return false;
    }
    ...

private:
    ...
    Presets preset{Presets::Core};

    using SandboxPresets = std::map<Presets, Libs>;
    inline static const SandboxPresets sandboxPresets{
        {Presets::Core, {}},
        {Presets::Minimal,
            {sol::lib::base,
             sol::lib::table}},
        {Presets::Complete,
            {sol::lib::base,
             sol::lib::coroutine,
             sol::lib::math,
             sol::lib::os,
             sol::lib::string,
             sol::lib::table}},
        {Presets::Custom, {}}
    };
};

// Добавим в reset() загрузку библиотек после сброса.
// И, т.к. он у нас несколько распух — вынесем в cpp-файл.
void LuaSandbox::reset(bool doCollectGrbg /* = false */)
{
    sandbox = sol::environment(lua->state, sol::create);
    sandbox["_G"] = sandbox;

    if (loadedLibs.empty()) {
        // Для конструктора используем список из пресета.
        loadLibs(sandboxPresets.at(preset));
    } else {
        // Если же инкарнация уже не первая, то грузим всё, что было в прошлой жизни.
        loadLibs(loadedLibs);
    }
    // Принудительная уборка мусора, в случае необходимости
    if (doCollectGrbg) {
        lua->state.collect_garbage();
    }
}

С основным, вроде, разобрались. Но объём уже и так более чем существенный получился, поэтому продолжим в следующих частях, где займёмся возможностью запуска файлов со скриптами из самих скриптов, контролем путей и ограничением ресурсов.