El entorno del chunk: cargar con su propio _ENV
El cuarto argumento de `load` fija el primer upvalue del chunk, y de ahí salen las dos aplicaciones más importantes de la metaprogramación en Lua: la configuración como código, donde un archivo ejecutable se convierte en una tabla de datos, y el sandboxing, cuya frontera real no es la visibilidad de un nombre sino la alcanzabilidad de un objeto.
El nivel 9 estableció que un chunk se compila siempre dentro de una variable externa llamada _ENV y que todo nombre libre es un campo de esa variable. La lección anterior añadió que load devuelve el chunk como una función corriente. Junta las dos frases y obtienes la consecuencia más productiva de todo el nivel: si puedes decidir el valor de ese upvalue en el momento de cargar, puedes decidir qué mundo existe para el código que acabas de compilar. De esa capacidad de una sola línea salen la configuración como código y el sandboxing, que son la misma técnica mirada desde dos intenciones opuestas: una quiere recoger lo que el chunk escribe, la otra quiere limitar lo que el chunk alcanza.
- Usar el cuarto argumento de
loady verificar condebug.getupvaluedónde acaba el entorno. - Construir un cargador de configuración que convierte un archivo ejecutable en una tabla de datos.
- Interceptar lectura y escritura del entorno para detectar erratas en el momento de la carga.
- Enumerar las vías de fuga de un sandbox y distinguir la visibilidad de un nombre de la alcanzabilidad de un objeto.
El cuarto argumento
El chunk principal producido por load declara siempre un upvalue, y ese upvalue se llama _ENV. Cuando pasas el cuarto argumento, Lua lo instala ahí antes de devolverte la función; cuando no lo pasas, instala el entorno global.
local mundo = { print = print }
local f = load("print(saludo)", "=prueba", "t", mundo)
print(debug.getupvalue(f, 1)) --> _ENV table: 0x...
mundo.saludo = "hola"
f() --> hola
saludo = "adios" -- global del anfitrion
f() --> hola el chunk no lo ve
No hay ninguna otra pieza en juego. El chunk escribe print(saludo), el compilador lo reescribe como _ENV.print(_ENV.saludo), y _ENV es la tabla que le diste. Como la instalación ocurre sobre un upvalue ordinario, también puedes cambiarla después sin recompilar:
local otro = { print = print, saludo = "buenas" }
debug.setupvalue(f, 1, otro)
f() --> buenas
Compilar una vez y ejecutar contra entornos distintos es, por tanto, una operación de coste constante. Una plantilla compilada, una regla de negocio, un script de usuario: todos se compilan al arrancar y se ejecutan después tantas veces como haga falta, cada vez contra una tabla distinta. Ese desacoplamiento entre el código y su mundo es lo que hace que la técnica escale.
Configuración como código
Si todo asignación a un nombre libre es una escritura en el entorno, entonces un archivo de configuración escrito en Lua es una tabla: basta con darle una tabla vacía y recogerla al terminar.
local function cargar_config(ruta)
local datos = {}
local chunk, err = loadfile(ruta, "t", datos)
if not chunk then return nil, err end
local ok, e = pcall(chunk)
if not ok then return nil, e end
return datos
end
Y el archivo de configuración es Lua legítimo, no un formato:
tema = "catppuccin"
numeros = true
tab = 2
plugins = { "telescope", "treesitter", "fugitive" }
for i = 1, 9 do
atajos["espacio" .. i] = "ir a la pestana " .. i
end
Lo que se gana frente a un formato de datos es exactamente lo que se pierde en garantías, y conviene enunciarlo sin adornos. Se gana el lenguaje completo: bucles, condicionales, funciones auxiliares, composición de valores, reutilización entre archivos. Se pierde la terminación garantizada —un archivo de configuración puede colgarse— y la posibilidad de analizarlo sin ejecutarlo, que es lo que permite a una herramienta externa reescribir un archivo JSON conservando comentarios. Neovim, nginx con su módulo de Lua y el redis embebido viven todos en el primer lado de ese intercambio, y no por casualidad: cuando la configuración describe comportamiento y no solo valores, un formato de datos acaba creciendo hasta convertirse en un mal lenguaje de programación.
La versión seria del patrón no entrega una tabla vacía sino una con metatabla, y ahí es donde la técnica supera a cualquier formato. Interceptando la lectura se ofrece un vocabulario controlado y se detecta el uso de un nombre inexistente; interceptando la escritura se detecta la errata en el nombre del campo, en el instante de la carga y con número de línea.
local campos = { tema = true, numeros = true, tab = true, plugins = true }
local vocabulario = { rojo = "#f38ba8", azul = "#89b4fa", ipairs = ipairs }
local datos = {}
local entorno = setmetatable({}, {
__index = function(_, k)
local v = vocabulario[k]
if v == nil then error("nombre no disponible aqui: " .. k, 2) end
return v
end,
__newindex = function(_, k, v)
if not campos[k] then error("campo de configuracion desconocido: " .. k, 2) end
rawset(datos, k, v)
end,
})
El segundo argumento de error hace que la posición señalada sea la del archivo de configuración y no la del metamétodo, que es lo que convierte el mensaje en algo accionable. Ninguna configuración en JSON o TOML detecta una errata en el nombre de una clave sin un esquema aparte; aquí sale gratis del mecanismo de metatablas.
Una tabla vacía como entorno significa que el chunk no tiene print, ni pairs, ni type, ni tostring. Lo habitual es partir de la tabla vacía y añadir lo que se necesita, nunca partir del entorno global y quitar. Copiar _G y borrar campos deja siempre algo dentro: la lista de lo peligroso crece con cada versión del lenguaje, la de lo necesario no.
Lo que el entorno no protege
Aquí empieza la parte que se enseña mal. Un entorno restringido controla qué nombres se resuelven, y eso no es lo mismo que controlar qué objetos son alcanzables. Cualquier objeto al que se llegue por una cadena de indexaciones desde el entorno está concedido, aunque no tenga nombre propio en él.
flowchart TD E[Entorno del chunk] --> A[Nombres concedidos explicitamente] A --> B[Objetos alcanzables por indexacion] B --> C[Metatablas de esos objetos] C --> D[Metatabla compartida de las cadenas] D --> F[Biblioteca string del anfitrion] A --> G[Cualquier funcion del anfitrion pasada al invitado] G --> H[Sus upvalues incluido su propio _ENV] H --> I[Fuga completa si el invitado tiene debug]
La lista de vías de fuga es corta y hay que conocerla entera.
La biblioteca debug anula el mecanismo por completo: con debug.getupvalue sobre cualquier función del anfitrión que llegue al invitado se recupera su _ENV, y debug.getregistry entrega directamente el entorno global desde el registro de C. Un sandbox que exponga debug no es un sandbox.
Las cadenas comparten una única metatabla en todo el estado de Lua. Si el invitado puede escribir un literal de cadena —siempre puede—, alcanza la biblioteca string a través de ella, lo cual suele ser inofensivo; lo que no es inofensivo es que pueda modificarla, porque esa mutación afecta al anfitrión y a todos los demás invitados. La defensa se aplica desde fuera, protegiendo esa metatabla con el campo __metatable antes de crear ningún sandbox.
Exponer load dentro del sandbox es aceptable solo envuelto: hay que forzar el modo de texto y el entorno, porque en otro caso el invitado carga bytecode manipulado y sale del proceso de Lua por completo, como vimos en 20.1. Y require con package concede el sistema de archivos y la carga de bibliotecas en C, que es la fuga total.
Queda lo que ningún entorno puede tocar: el tiempo y la memoria. Un bucle vacío consume una CPU indefinidamente y una concatenación en bucle agota la memoria del proceso, y ambas cosas ocurren sin nombrar un solo símbolo prohibido. Sus contenciones viven fuera del lenguaje: un hook de cuenta de instrucciones instalado por el anfitrión, que es el asunto de la lección 20.4, y un asignador de memoria con límite instalado desde la API C.
Las cuatro fronteras
Un sandbox que merezca ese nombre defiende cuatro cosas distintas, y el entorno solo defiende la primera.
Nombres
Qué símbolos se resuelven. Es lo que hace _ENV, y es la parte fácil. Se construye por lista blanca desde la tabla vacía.
Tiempo
Cuántas instrucciones puede ejecutar antes de que el anfitrión lo interrumpa. Solo se consigue con un hook de cuenta, y hay que instalarlo también en cada corrutina que el invitado cree.
Espacio
Cuánta memoria puede reservar. Vive por debajo del lenguaje, en el asignador que se pasa al crear el estado desde C.
Estado compartido
Metatabla de las cadenas, registro, package.loaded y cualquier tabla del anfitrión que se preste. Es la frontera que más veces se olvida y la única que permite a un invitado atacar a otro.
El error conceptual que arruina la mayoría de los sandboxes escritos en Lua consiste en tratar el entorno como una lista de permisos, cuando lo que es en realidad es la raíz de un grafo. Al entregar una tabla como _ENV no estás concediendo los nombres que contiene: estás concediendo la clausura transitiva de todo lo que se alcanza desde ella por indexación, por llamada, por metatabla y por valor de retorno. Una función inocente que el invitado pueda llamar y que devuelva un objeto del anfitrión acaba de ampliar el grafo sin que nadie haya tocado el entorno. Esta es exactamente la noción de capacidad que formalizó la tradición de los sistemas de capacidades de objetos, y Lua la implementa por accidente, sin proponérselo, porque una referencia alcanzable a un objeto es indistinguible de la autoridad para usarlo. La consecuencia práctica es que auditar un sandbox nunca consiste en leer la lista de nombres del entorno: consiste en recorrer el grafo y preguntarse, para cada arista, qué se puede hacer con lo que hay al otro lado. Y hay una consecuencia más profunda todavía. Un sandbox de nombres es una propiedad local de una tabla; la seguridad es una propiedad global del sistema, que incluye el tiempo de CPU, la memoria, el estado compartido entre invitados, los mensajes de error que filtran rutas del sistema de archivos y el hecho, nada menor, de que el intérprete está escrito en C y comparte espacio de direcciones con la aplicación anfitriona. Por eso los autores de Lua nunca han afirmado que el lenguaje ofrezca aislamiento: ofrecen un mecanismo de resolución de nombres que resulta ser una pieza excelente de un aislamiento que tienes que construir tú, y que en los casos serios —el motor de un juego, un servidor multiusuario— acaba incluyendo un estado de Lua entero por invitado, con su propio asignador, su propio hook y ninguna tabla en común. La disciplina correcta no es preguntarse qué le quito al entorno, sino qué es lo mínimo que necesita el invitado, y construir hacia arriba desde la nada.
- Carga un chunk con una tabla como entorno y comprueba con
debug.getupvalueque ha aterrizado en el upvalue uno. Cámbialo después condebug.setupvaluey verifica que el comportamiento cambia sin recompilar. - Escribe un cargador de configuración de diez líneas y un archivo de configuración que use un bucle para generar veinte campos. Recoge el resultado y muéstralo.
- Añade al entorno una metatabla que rechace campos desconocidos con
errorde nivel dos. Provoca una errata y comprueba que el mensaje señala la línea del archivo de configuración. - Dale al invitado un entorno que contenga únicamente
print. Desde dentro, alcanza la bibliotecastringpartiendo de un literal de cadena y explica por qué funciona. - Añade
debuga ese entorno y recupera el entorno global del anfitrión desde dentro del sandbox. Anota cuántas líneas te ha costado.