Если бы меня попросили составить список наиболее часто используемых не по делу страшилок в нашей профессии, я бы на первое место с большим отрывом поставил мантру «никогда, ни при каких обстоятельствах, не используйте eval
». Потому что это небезопасно, а-та-та. (Для скучающих я привел внизу пополняемый список ненавистных мне идиотских обобщений, рассчитанных на новичков и тупых.)
Как все без исключения догмы, эта тоже имеет крайне ограниченную область применения. Из того, что не нужно разрешать случайным посетителям сайта выполнять любой код на сервере — никак не следует, что eval
— чем-то плох per se. В конце концов, зачем-то его ведь придумали и добавили в такую прорву самых разных языков.
Отчасти это поверие связано с тем, что авторы множества языков не озаботились средствами для работы с AST на уровне компилятора, и чтобы не допустить случайную ошибку и не взорвать исполнением кода по месту — приложение вместе с сервером и датацентром — приходится написать свою неспецифицированную, глючную и медленную реализацию половины компилятора. Отчасти — тем, что некоторые языки плохо (или никак) защищены от всякого рода инъекций, и проще всего вылечить перхоть — усекновением главы. На оставшиеся 98% — это просто религиозный запрет, наподобие отказа от свинины.
Вспоминается случай, как в какой-то из своих библиотек на руби я переписал class_eval
для плагина — на prepend
’ах, когда они появились, просто потому, что я устал закрывать issues в гитхабе, указывающие на небезопасность подхода (хотя плагин мог добавить только автор кода, использующего мою библиотеку, имеющий, в принципе, полную власть над кодом, вплоть до вызова `rm -rf /`
, если ему втемяшится всё сломать).
Поэтому когда нам потребовалось разрешить клиентам вводить и исполнять собственные арифметические формулы (что-то типа счетных полей, заданных пользователем) — я знал, что решение на эвалах мои коллеги не поймут. Писать свой «калькулятор», или взять готовый, — мне показалось плохой идеей, потому что завтра пользователи захотят использовать модули, послезавтра — целочисленное деление, а через год — арксинусы. Я не отношусь к категории людей, которым нравится развивать свои библиотеки вечно; я стараюсь сразу написать их так, чтобы в будущем (после фиксации версии 1.0.0
) — только проверять работоспособность на новых версиях компилятора (за меня это делают Github Actions).
В общем, мне явно была нужна песочница для вызова пользовательского кода, и я принялся думать, как бы мне её реализовать в эликсире. Для нетерпеливых — вот библиотека Formulae.
Мертворожденные варианты
Первой посетившей меня мыслью, разумеется, была блистательная идея запускать новую ноду для каждого пользователя в изолированном контейнере. Я спросил у девопсов, во сколько нам это обойдется, и еле ушел оттуда целым и невредимым.
Потом я стал ковыряться с изолированными процессами, но OTP писали во времена, когда ножей не знали, и говядину целиком ели, поэтому внутри кластера — почти как в Лас Вегасе — все друг другу доверяют и сообщение можно отправить куда угодно. Я поигрался с собственной реализацией :gen_server
, перехватом исходящих сообщений, кастрацией импортов и прочим неликвидом и понял, что кроме седых волос мне этот подход не принесет ничего.
Тогда я попробовал валидировать «формулы», приходящие от пользователей, но не преуспел и тут, потому что оставалось неясным, как сопрячь эту валидацию с фактически неограниченными потенциально возможностями, которых я все-таки не хотел лишать продвинутых пользователей, не представляющих свою жизнь без арктангенсов и гамильтонианов.
И тут меня осенило.
Модуль — как капсула, ограничивающая вызовы стороннего кода
Эликсир (вслед за эрлангом) организует код при помощи модулей. Из функции модуля можно вызвать только функции, определенные в том же модуле, функции, явно импортированные из других модулей, функции модуля Kernel
и remote fully-qualified functions (я не знаю, как это перевести понятно, имеется в виду вызов с явным указанием модуля, типа System.cmd("ls")
).
Поэтому, если мы явно проконтролируем импорты, запретим вызов небезопасных функций из Kernel
, и будем выполнять пользовательский код в функции модуля, мы получим ровно ту песочницу, которая нам нужна.
Останется лишь скомпилировать модуль для пользователя, сообразно установленным правилам, и — voilà — песочница готова. Изнутри модуля не получить доступ ни к чему, кроме напрямую разрешенных вызовов. Удаленные (remote, не removed) вызовы очень просто запретить пройдясь по AST формулы, в Kernel
всего пять или типа того небезопасных вызовов, которые можно занести в черный список руками.
Осталось прояснить несколько архитектурных вопросов, и можно приступать к написанию кода.
Оставшиеся неясности
Во-первых, надо бы переиспользовать скомпилированные модули для разных пользователей: компиляция — не самое дешевое действие, а в рамках засилия контейнеров мы уже не можем надеяться на годы в качестве времени жизни нашего сервиса, то есть при каждом рестарте — нам придется компилировать формулы заново. Я уж было затеялся с глобальным списком скомпилированных формул и соответствующих им модулей, но тут вспомнил, что никто, лучше компилятора, не знает, был модуль уже скомпилирован, или нет. Поэтому достаточно просто назвать модуль в честь формулы, и все остальное за нас сделает сам компилятор. Мы убиваем сразу двух зайцев: не нужно генерировать имя модуля, и не нужно проверять, существует ли уже нужный модуль. Итак, для условной формулы a > 42
, мы скомпилируем модуль по имени :"Elixir.Formulae.a > 42"
. Да, так можно: это атом, а единственное ограничение на имена модулей в эликсире и эрланге — имя должно быть атомом.
Далее, я хотел дать возможность на серверной стороне разрешать сложные вызовы функций из других модулей, поэтому при генерации необходимо получить и обработать белый список специально дозволенных вызовов. Здесь я пошел по пути наименьшего сопротивления: я просто импортирую эксплицитно разрешенные функции, чтобы не возиться с проверкой удалённых вызовов (они по-прежнему запрещены). Если пользователю нужна функция pi/0
из модуля :math
, он может сделать что-то типа такого:
f = Formulae.compile("pi() + 2", imports: [{:math, pi: 0}])
assert Formulae.eval(f, []) > 5
При этом сами формулы приходят от пользователя, а что кому разрешать импортировать — решает администратор сервиса.
Остались малозначительные штрихи: максимально подробный экспорт информации о формуле, каррирование, и (мой личный фетиш) — переиспользование формул в виду гардов, когда возможно.
А еще, естественно, мне захотелось облегчить написание кода с использованием этой библиотеки, поэтому она поддерживает сигил ~F[…]
.
Лучше один раз увидеть
Вот итоговый вариант после компиляции:
iex|🌢|1 ▶ import Formulae.Sigils
Formulae.Sigils
iex|🌢|2 ▶ f = ~F[x / y > 42]
#ℱ<[
sigil: "~F[x / y > 42]",
eval: &:"Elixir.Formulae.x ÷ y > 42".eval/1,
formula: "x / y > 42",
guard: nil,
module: :"Elixir.Formulae.x ÷ y > 42",
variables: [:x, :y],
options: […]
]>
iex|🌢|3 ▶ f.eval.(x: 43, y: 1)
true
iex|🌢|4 ▶ f.eval.(x: 42, y: 1)
false
Подробнее — в документации. Можете попробовать эту песочницу взломать — я как Кнут, выплачиваю по :math.pow(:math.pi(), n)
евро за каждую уязвимость, где n
— порядковый номер уязвимости.
Удачного формулирования!
P. S. Неполный список нелепых догм (ака хороших практик)
① eval
— несомненное зло
② длина функции/метода не должна превышать три (пять, десять) строк кода
③ чужая библиотека всегда лучше своего велосипеда
④ существует парадигма, в которой удобнее решать любые задачи
⑤ недопустимо использование в коде символов, выходящих за пределы ASCII-7
⑥ для выделения абстракции требуется как минимум два (три, пять) случаев копипасты
⑦ хорошая среда разработки может помочь с рефакторингом
⑧ чем выше покрытие тестами — тем надежнее код
⑨ статическая типизация всегда помогает
⑩ читаемость кода является показателем его качества