lua fmt, clean up libs

master
Ondřej Hruška 2 weeks ago
parent 8160815e3f
commit e3f580c161
  1. 8
      .stylua.toml
  2. 12
      README.md
  3. 44
      lua/coroutines-demo.lua
  4. 32
      lua/stdlib/http.lua
  5. 99
      lua/stdlib/math.lua
  6. 15
      lua/stdlib/sqlite.lua
  7. 340
      lua/stdlib/table.lua
  8. 122
      lua/stdlib/utils.lua
  9. 16
      lua/test.lua
  10. 10
      lua/test_sqlite.lua
  11. 313
      src/lua_tests/async_tests.rs
  12. 226
      src/lua_tests/core_tests.rs
  13. 398
      src/lua_tests/math_tests.rs
  14. 41
      src/lua_tests/mod.rs
  15. 173
      src/lua_tests/os_tests.rs
  16. 1497
      src/lua_tests/table_tests.rs
  17. 813
      src/lua_tests/utils_tests.rs
  18. 36
      src/main.rs
  19. 21
      src/repl.rs
  20. 158
      src/stdlib/http.rs
  21. 37
      src/stdlib/logging.rs
  22. 116
      src/stdlib/lua_dump.rs
  23. 1
      src/stdlib/mod.rs
  24. 30
      src/stdlib/os_ext.rs
  25. 117
      src/stdlib/sqlite.rs
  26. 23
      src/stdlib/task.rs
  27. 49
      src/stdlib/utils.rs

@ -0,0 +1,8 @@
column_width = 120
line_endings = "Unix"
indent_type = "Spaces"
indent_width = 4
quote_style = "AutoPreferDouble"
call_parentheses = "Always"
# never allow `if x then y end` on one line — always expand after then/do
collapse_simple_statement = "Never"

@ -1,6 +1,6 @@
# `a` # `a`
`a` embeds a Lua interpreter and adds a standard library and IO access, so a script can reach the network, a database, the filesystem, and the terminal without additional setup. `a` embeds a Lua interpreter and adds a standard library and IO access, so a script can reach the outside world — today an HTTP client and SQLite, with more of the surface below planned — without additional setup.
```sh ```sh
a file.lua # run a script a file.lua # run a script
@ -38,10 +38,14 @@ The result is a single binary that runs a `.lua` file. There is no package manag
## The standard library ## The standard library
The standard library is the main purpose of `a`. Planned surface: The standard library is the main purpose of `a`. Available today:
- **Networking** — HTTP client, WebSocket, and MQTT (3.1.1 and 5). - **Networking** — HTTP client (WebSocket and MQTT are planned).
- **SQLite** — access to embedded SQLite databases. - **SQLite** — access to embedded SQLite databases.
- **Core helpers**`math`, `table`, and `utils` extensions, structured `log`, `os` time helpers, and `task.join` concurrency.
Planned:
- **Filesystem** — read/write/glob/walk without stock Lua's `io` boilerplate. - **Filesystem** — read/write/glob/walk without stock Lua's `io` boilerplate.
- **Terminal UI** — interactive line editing and full-screen TUI, comparable to `ncurses`. - **Terminal UI** — interactive line editing and full-screen TUI, comparable to `ncurses`.
@ -52,7 +56,7 @@ The intent is for the common case to be a single call rather than a multi-step s
- Embeds Lua 5.5 via `mlua`, on an async core ([`tokio`](https://tokio.rs/)) so IO-heavy scripts don't block. - Embeds Lua 5.5 via `mlua`, on an async core ([`tokio`](https://tokio.rs/)) so IO-heavy scripts don't block.
- A single self-contained binary. No LuaRocks, no make, no virtualenv. - A single self-contained binary. No LuaRocks, no make, no virtualenv.
- Scripts run in a **sandboxed** environment modeled on [Luau's safe environment](https://luau.org/sandbox): `io`, `package`, and `debug` are not loaded; `os` is trimmed to its time functions; the bytecode/chunk-loading escape hatches (`load`, `loadfile`, `dofile`, `string.dump`) are removed. The library `a` provides is the supported way to reach the outside world. - Scripts run in a **sandboxed** environment modeled on [Luau's safe environment](https://luau.org/sandbox): `io`, `package`, and `debug` are not loaded; `os` is trimmed to its time functions; the bytecode/chunk-loading escape hatches (`load`, `loadfile`, `dofile`, `string.dump`) are removed. The library `a` provides is the supported way to reach the outside world.
- A leading `#!` shebang line is skipped, so scripts can be made executable directly. - A leading `#!` shebang line is skipped (after an optional UTF-8 BOM), so scripts can be made executable directly. Script files need not be valid UTF-8 — Lua source is byte-oriented.
## Building ## Building

@ -3,7 +3,9 @@
-- --
-- Run with: cargo run -- lua/coroutines-demo.lua -- Run with: cargo run -- lua/coroutines-demo.lua
local function banner(s) print("\n=== " .. s .. " ===") end local function banner(s)
print("\n=== " .. s .. " ===")
end
---------------------------------------------------------------------- ----------------------------------------------------------------------
banner("1. Pure Lua coroutines (no async involved)") banner("1. Pure Lua coroutines (no async involved)")
@ -49,8 +51,9 @@ end)
local t1 = os.microtime() local t1 = os.microtime()
local first = sleeper() -- returns immediately with the sentinel local first = sleeper() -- returns immediately with the sentinel
print(string.format("first resume returned %s after only %.3fs (did NOT wait 0.5s)", print(
tostring(first), os.microtime() - t1)) string.format("first resume returned %s after only %.3fs (did NOT wait 0.5s)", tostring(first), os.microtime() - t1)
)
print("--> lesson: don't hand-drive coroutines that call async stdlib functions.") print("--> lesson: don't hand-drive coroutines that call async stdlib functions.")
---------------------------------------------------------------------- ----------------------------------------------------------------------
@ -69,31 +72,34 @@ local function worker(name, secs)
end end
local t2 = os.microtime() local t2 = os.microtime()
local a, b, c = task.join( local a, b, c = task.join(worker("slow", 0.30), worker("med", 0.20), worker("fast", 0.10))
worker("slow", 0.30),
worker("med", 0.20),
worker("fast", 0.10)
)
local elapsed = os.microtime() - t2 local elapsed = os.microtime() - t2
print(string.format("results: %s, %s, %s", a, b, c)) print(string.format("results: %s, %s, %s", a, b, c))
print(string.format("wall time: %.3fs", elapsed)) print(string.format("wall time: %.3fs", elapsed))
print(string.format("sequential would have been ~0.60s; concurrent ~0.30s => %s", print(
elapsed < 0.45 and "CONCURRENT (overlapped on tokio)" or "serialized?!")) string.format(
"sequential would have been ~0.60s; concurrent ~0.30s => %s",
elapsed < 0.45 and "CONCURRENT (overlapped on tokio)" or "serialized?!"
)
)
---------------------------------------------------------------------- ----------------------------------------------------------------------
banner("5. Nested + return values") banner("5. Nested + return values")
---------------------------------------------------------------------- ----------------------------------------------------------------------
-- task.join itself yields, so it composes: a joined task can join again. -- task.join itself yields, so it composes: a joined task can join again.
local outer = task.join( local outer = task.join(function()
function() local x, y = task.join(function()
local x, y = task.join( os.sleep(0.05)
function() os.sleep(0.05); return 21 end, return 21
function() os.sleep(0.05); return 21 end end, function()
) os.sleep(0.05)
return 21
end)
return x + y return x + y
end, end, function()
function() os.sleep(0.10); return "sibling" end os.sleep(0.10)
) return "sibling"
end)
print("nested join result:", outer) -- 42 print("nested join result:", outer) -- 42
print("\nAll demos finished.") print("\nAll demos finished.")

@ -5,7 +5,9 @@
-- Attach resp.json() to a response table: parses resp.body via utils.fromJSON. -- Attach resp.json() to a response table: parses resp.body via utils.fromJSON.
local function wrap_resp(resp) local function wrap_resp(resp)
resp.json = function() return utils.fromJSON(resp.body) end resp.json = function()
return utils.fromJSON(resp.body)
end
return resp return resp
end end
@ -17,7 +19,9 @@ end
-- Method shorthands for the stateless module. -- Method shorthands for the stateless module.
for _, m in ipairs({ "get", "post", "put", "patch", "delete", "head" }) do for _, m in ipairs({ "get", "post", "put", "patch", "delete", "head" }) do
http[m] = function(url, opts) return http.request(m:upper(), url, opts) end http[m] = function(url, opts)
return http.request(m:upper(), url, opts)
end
end end
function http.getJSON(url, opts) function http.getJSON(url, opts)
@ -41,12 +45,24 @@ function session_mt:request(method, url, opts)
return wrap_resp(self:_request(method, url, opts)) return wrap_resp(self:_request(method, url, opts))
end end
function session_mt:get(url, opts) return self:request("GET", url, opts) end function session_mt:get(url, opts)
function session_mt:post(url, opts) return self:request("POST", url, opts) end return self:request("GET", url, opts)
function session_mt:put(url, opts) return self:request("PUT", url, opts) end end
function session_mt:patch(url, opts) return self:request("PATCH", url, opts) end function session_mt:post(url, opts)
function session_mt:delete(url, opts) return self:request("DELETE", url, opts) end return self:request("POST", url, opts)
function session_mt:head(url, opts) return self:request("HEAD", url, opts) end end
function session_mt:put(url, opts)
return self:request("PUT", url, opts)
end
function session_mt:patch(url, opts)
return self:request("PATCH", url, opts)
end
function session_mt:delete(url, opts)
return self:request("DELETE", url, opts)
end
function session_mt:head(url, opts)
return self:request("HEAD", url, opts)
end
function session_mt:getJSON(url, opts) function session_mt:getJSON(url, opts)
local resp = self:get(url, opts) local resp = self:get(url, opts)

@ -11,8 +11,16 @@ local function roundHalfAwayFromZero(x)
return i return i
end end
--- Round a number to the nearest integer, or to N decimal places. --- @brief Round a number to the nearest integer, or to N decimal places.
--- Rounds half away from zero. round(value, 0) returns an integer; round(value, places > 0) returns a float. ---
--- round(value) and round(value, 0) return an integer; round(value, places > 0) returns a float.
--- Rounds half away from zero. Errors on non-numbers, NaN and infinity, on negative or
--- non-integer places, and (in the integer form) on results outside the Lua integer range.
--- If value * 10^places overflows, rounding cannot change the value and it is returned unchanged.
---
--- @param value number: Value to round
--- @param places number|nil: Optional number of decimal places, default 0 (must be a non-negative integer)
--- @return number: The rounded value
function math.round(value, places) function math.round(value, places)
if type(value) ~= "number" then if type(value) ~= "number" then
error("round() called with a non-number value") error("round() called with a non-number value")
@ -34,7 +42,7 @@ function math.round(value, places)
if places == 0 then if places == 0 then
return value return value
end end
return value + 0.0 return value + 0.0 -- an integer has no decimals to round, but the contract says float
end end
if not math.isFinite(value) then if not math.isFinite(value) then
@ -52,64 +60,113 @@ function math.round(value, places)
local mul = 10.0 ^ places local mul = 10.0 ^ places
local scaled = value * mul local scaled = value * mul
if not math.isFinite(scaled) then if not math.isFinite(scaled) then
-- rounding cannot change a value of this magnitude
return value return value
end end
return roundHalfAwayFromZero(scaled) / mul return roundHalfAwayFromZero(scaled) / mul
end end
--- Clamp a number to an interval [min, max]. Either bound may be nil (half-open). --- @brief Clamp number to an interval, optionally half-open.
--- If both bounds are nil, just returns the value.
--- Errors if any of the params is in wrong type (value must be number, min and max must be number or nil),
--- or if min is greater than max.
--- @param value number: Value to clamp
--- @param min number|nil: Lower bound, or nil for no lower bound
--- @param max number|nil: Upper bound, or nil for no upper bound
--- @return number: Value clamped to the interval
function math.clamp(value, min, max) function math.clamp(value, min, max)
if type(value) ~= "number" then if type(value) ~= "number" then
error("Non-number passed to clamp() as value") error("Non-number passed to clamp() as value")
end end
if min ~= nil and type(min) ~= "number" then if min ~= nil and type(min) ~= "number" then
error("Non-number passed to clamp() as minimum") error("Non-number passed to clamp() as minimum")
end end
if max ~= nil and type(max) ~= "number" then if max ~= nil and type(max) ~= "number" then
error("Non-number passed to clamp() as maximum") error("Non-number passed to clamp() as maximum")
end end
if min ~= nil and max ~= nil and min > max then if min ~= nil and max ~= nil and min > max then
error("clamp() minimum is greater than maximum") error("clamp() minimum is greater than maximum")
end end
if min ~= nil and value < min then return min end
if max ~= nil and value > max then return max end if min ~= nil and value < min then
return min
end
if max ~= nil and value > max then
return max
end
return value return value
end end
--- Return true if value is a finite number (not NaN or infinity). --- @brief Check if a value is a finite number (not NaN or infinity)
--- @param value any: Value to check
--- @return boolean: True if the value is a finite number; false for NaN, infinity and non-number values
function math.isFinite(value) function math.isFinite(value)
if type(value) ~= "number" then return false end if type(value) ~= "number" then
return false
end
-- NaN is the only value not equal to itself
-- math.huge represents infinity
return value == value and value ~= math.huge and value ~= -math.huge return value == value and value ~= math.huge and value ~= -math.huge
end end
--- Return the sign of a number: -1, 0, or 1. NaN yields 0. --- @brief Get the sign of a number: -1 for negative, 0 for zero, 1 for positive.
--- Note: NaN has no sign and yields 0.
--- @param value number: Value to take the sign of
--- @return number: -1, 0 or 1
function math.sign(value) function math.sign(value)
if type(value) ~= "number" then if type(value) ~= "number" then
error("Non-number passed to sign()") error("Non-number passed to sign()")
end end
if value > 0 then return 1 if value > 0 then
elseif value < 0 then return -1 return 1
else return 0 elseif value < 0 then
return -1
else
return 0
end end
end end
--- Scale a value linearly from [inMin, inMax] to [outMin, outMax]. --- @brief Scale a value from one range to another (linear interpolation).
--- If clamp is true, the result is clamped to the output range. --- @param value number: The input value to scale
--- @param inMin number: The minimum of the input range
--- @param inMax number: The maximum of the input range
--- @param outMin number: The minimum of the output range
--- @param outMax number: The maximum of the output range
--- @param clamp boolean: If true, clamp the result to [outMin, outMax]. Default is false.
--- @return number: The scaled value
function math.scale(value, inMin, inMax, outMin, outMax, clamp) function math.scale(value, inMin, inMax, outMin, outMax, clamp)
if type(value) ~= "number" then error("Non-number passed to scale() as value") end if type(value) ~= "number" then
if type(inMin) ~= "number" or type(inMax) ~= "number" then error("Non-number passed to scale() as input range") end error("Non-number passed to scale() as value")
if type(outMin) ~= "number" or type(outMax) ~= "number" then error("Non-number passed to scale() as output range") end end
if inMin == inMax then error("Input range cannot be zero (inMin == inMax)") end if type(inMin) ~= "number" or type(inMax) ~= "number" then
error("Non-number passed to scale() as input range")
end
if type(outMin) ~= "number" or type(outMax) ~= "number" then
error("Non-number passed to scale() as output range")
end
if inMin == inMax then
error("Input range cannot be zero (inMin == inMax)")
end
local ratio = (value - inMin) / (inMax - inMin) local ratio = (value - inMin) / (inMax - inMin)
local result = outMin + ratio * (outMax - outMin) local result = outMin + ratio * (outMax - outMin)
if clamp then if clamp then
local lo, hi = outMin, outMax local lo, hi = outMin, outMax
if lo > hi then lo, hi = hi, lo end if lo > hi then
if result < lo then return lo lo, hi = hi, lo
elseif result > hi then return hi
end end
if result < lo then
return lo
elseif result > hi then
return hi
end end
end
return result return result
end end

@ -23,18 +23,21 @@ return function(Connection)
end end
--- Run `fn` inside a transaction: commit on success, roll back and re-raise --- Run `fn` inside a transaction: commit on success, roll back and re-raise
--- on error. The original error is re-raised unchanged (level 0). --- on error. The original error is re-raised unchanged (level 0), and fn's
--- return values are passed through on success.
function Connection:transaction(fn) function Connection:transaction(fn)
if type(fn) ~= "function" then if type(fn) ~= "function" then
error("sqlite: transaction requires a function argument (got " .. type(fn) .. ")", 2) error("sqlite: transaction requires a function argument (got " .. type(fn) .. ")", 2)
end end
self:begin() self:begin()
local ok, err = pcall(fn) local results = table.pack(pcall(fn))
if ok then if results[1] then
self:commit() self:commit()
else return table.unpack(results, 2, results.n)
self:rollback()
error(err, 0)
end end
-- On failure, roll back but never let a rollback error mask the original
-- one (the connection may already be closed, or no transaction active).
pcall(self.rollback, self)
error(results[2], 0)
end end
end end

@ -1,5 +1,16 @@
--- Make a read-only proxy of a table. Reads pass through; writes raise an error. --- @brief Make a read-only view of a table.
--- Caveat: pairs(), next() and # see no contents (proxy is empty). Intended for API namespaces. ---
--- Returns a proxy table: reads pass through to the original table, writes raise an error.
--- The original table is not modified and stays writable - changes to it show through the proxy.
--- The proxy's metatable is protected (getmetatable returns false).
---
--- Caveat: the proxy itself is empty - pairs(), next() and the # operator see no contents,
--- and the table.* helpers built on them (keys, values, contains, isEmpty, ...) treat it
--- as empty. Intended for protecting API namespace tables, not for data tables.
---
--- @param tbl table: Table to protect
--- @param name string: Optional name of the table, used in error messages
--- @return table: Read-only proxy of the table
function table.readonly(tbl, name) function table.readonly(tbl, name)
if type(tbl) ~= "table" then if type(tbl) ~= "table" then
error("table.readonly: argument is not a table (got " .. type(tbl) .. ")") error("table.readonly: argument is not a table (got " .. type(tbl) .. ")")
@ -7,73 +18,132 @@ function table.readonly(tbl, name)
if name ~= nil and type(name) ~= "string" then if name ~= nil and type(name) ~= "string" then
error("table.readonly: name is not a string (got " .. type(name) .. ")") error("table.readonly: name is not a string (got " .. type(name) .. ")")
end end
local message = "Attempt to update a read-only table" local message = "Attempt to update a read-only table"
if name ~= nil then message = message .. " " .. name end if name ~= nil then
message = message .. " " .. name
end
return setmetatable({}, { return setmetatable({}, {
__index = tbl, __index = tbl,
__newindex = function() error(message, 2) end, __newindex = function()
-- level 2 blames the assignment in the caller's code
error(message, 2)
end,
__metatable = false, __metatable = false,
}) })
end end
--- Filter a table by predicate, preserving keys. May leave gaps in a sequence table. --- @brief: Filters a table based on a predicate, all values in the table are run through this predicate; the ones that apply stay.
--- Keys are preserved - filtering a numbered table may leave gaps, use table.ifilter for those.
--- @param tbl table: Table to filter
--- @param predicate function: The predicate/filtering function, e.g. function(v) return v <= 5 end - this keeps only values up to 5
--- @return table: Product of the operation (new table)
function table.filter(tbl, predicate) function table.filter(tbl, predicate)
if type(tbl) ~= "table" then error("table.filter: table argument is not a table (got " .. type(tbl) .. ")") end if type(tbl) ~= "table" then
if type(predicate) ~= "function" then error("table.filter: predicate is not a function (got " .. type(predicate) .. ")") end error("table.filter: table argument is not a table (got " .. type(tbl) .. ")")
end
if type(predicate) ~= "function" then
error("table.filter: predicate is not a function (got " .. type(predicate) .. ")")
end
local result = {} local result = {}
for k, v in pairs(tbl) do for k, v in pairs(tbl) do
if predicate(v) then result[k] = v end if predicate(v) then
result[k] = v
end
end end
return result return result
end end
--- Filter a sequence table by predicate, producing a new gapless sequence. --- @brief: Filters a numbered table based on a predicate, all values in the table are run through this predicate; the ones that apply stay. Produces a new numbered table without gaps.
--- @param tbl table: Numbered table to filter
--- @param predicate function: The predicate/filtering function, e.g. function(v) return v <= 5 end - this keeps only values up to 5
--- @return table: Product of the operation (new table)
function table.ifilter(tbl, predicate) function table.ifilter(tbl, predicate)
if type(tbl) ~= "table" then error("table.ifilter: argument is not a table (got " .. type(tbl) .. ")") end if type(tbl) ~= "table" then
if type(predicate) ~= "function" then error("table.ifilter: predicate is not a function (got " .. type(predicate) .. ")") end error("table.ifilter: table argument is not a table (got " .. type(tbl) .. ")")
end
if type(predicate) ~= "function" then
error("table.ifilter: predicate is not a function (got " .. type(predicate) .. ")")
end
local result = {} local result = {}
for i = 1, #tbl do for i = 1, #tbl do
local v = tbl[i] local v = tbl[i]
if predicate(v) then result[#result + 1] = v end if predicate(v) then
result[#result + 1] = v
end
end end
return result return result
end end
--- Map values through a function, preserving keys. --- @brief: Maps the values in the table through a mapper function, preserving keys
--- @param tbl table: Table to map
--- @param mapper function: a mapping function, e.g. function(v) return v + 1 end
--- @return table: Product of the operation (new table)
function table.map(tbl, mapper) function table.map(tbl, mapper)
if type(tbl) ~= "table" then error("table.map: table argument is not a table (got " .. type(tbl) .. ")") end if type(tbl) ~= "table" then
if type(mapper) ~= "function" then error("table.map: mapper is not a function (got " .. type(mapper) .. ")") end error("table.map: table argument is not a table (got " .. type(tbl) .. ")")
end
if type(mapper) ~= "function" then
error("table.map: mapper is not a function (got " .. type(mapper) .. ")")
end
local result = {} local result = {}
for k, v in pairs(tbl) do result[k] = mapper(v) end for k, v in pairs(tbl) do
result[k] = mapper(v)
end
return result return result
end end
--- Map values through a function, dropping nils. May leave gaps in a sequence table. --- @brief: Maps the values in the table through a mapper function, preserving keys. If the function returns nil, the value is dropped. May leave gaps in a numbered table!
--- @param tbl table: Table to map
--- @param mapper function: a mapping function, e.g. function(v) return v + 1 end
--- @return table: Product of the operation (new table)
function table.filterMap(tbl, mapper) function table.filterMap(tbl, mapper)
if type(tbl) ~= "table" then error("table.filterMap: table argument is not a table (got " .. type(tbl) .. ")") end if type(tbl) ~= "table" then
if type(mapper) ~= "function" then error("table.filterMap: mapper is not a function (got " .. type(mapper) .. ")") end error("table.filterMap: table argument is not a table (got " .. type(tbl) .. ")")
end
if type(mapper) ~= "function" then
error("table.filterMap: mapper is not a function (got " .. type(mapper) .. ")")
end
local result = {} local result = {}
for k, v in pairs(tbl) do for k, v in pairs(tbl) do
local res = mapper(v) local res = mapper(v)
if res ~= nil then result[k] = res end if res ~= nil then
result[k] = res
end
end end
return result return result
end end
--- Map a sequence through a function, dropping nils, producing a gapless sequence. --- @brief: Maps the values in the numbered table through a mapper function. If the function returns nil, the value is dropped. Result is a numbered table without gaps.
--- @param tbl table: Numbered table to map
--- @param mapper function: a mapping function, e.g. function(v) return v + 1 end
--- @return table: Product of the operation (new table)
function table.ifilterMap(tbl, mapper) function table.ifilterMap(tbl, mapper)
if type(tbl) ~= "table" then error("table.ifilterMap: argument is not a table (got " .. type(tbl) .. ")") end if type(tbl) ~= "table" then
if type(mapper) ~= "function" then error("table.ifilterMap: mapper is not a function (got " .. type(mapper) .. ")") end error("table.ifilterMap: table argument is not a table (got " .. type(tbl) .. ")")
end
if type(mapper) ~= "function" then
error("table.ifilterMap: mapper is not a function (got " .. type(mapper) .. ")")
end
local result = {} local result = {}
for i = 1, #tbl do for i = 1, #tbl do
local res = mapper(tbl[i]) local res = mapper(tbl[i])
if res ~= nil then result[#result + 1] = res end if res ~= nil then
result[#result + 1] = res
end
end end
return result return result
end end
--- Merge tables into a new table. Sequence parts are appended in order; other keys are --- @brief Merge tables into a new table. The sequence part (consecutive positive integer keys
--- copied with later arguments overwriting earlier ones. Not recursive; not deep. --- starting at 1) of each table is appended in argument order; all other keys (including
--- non-sequence numeric keys) are copied over, values from later arguments overwriting earlier
--- ones. Not recursive: nested tables are copied by reference. Errors if an argument is not a table.
---
--- Metatable is not copied.
--- @param ... table: Tables to merge
--- @return table: Product of the operation (new table)
function table.merge(...) function table.merge(...)
local result = {} local result = {}
local n = 0 local n = 0
@ -100,18 +170,27 @@ end
local MERGE_DEPTH_LIMIT = 100 local MERGE_DEPTH_LIMIT = 100
local function deepCopy(value, level) local function deepCopy(value, level)
if type(value) ~= "table" then return value end if type(value) ~= "table" then
if level > MERGE_DEPTH_LIMIT then error("table.deepMerge/deepCopy recursion too deep") end return value
end
if level > MERGE_DEPTH_LIMIT then
error("table.deepMerge/deepCopy recursion too deep")
end
local result = {} local result = {}
for k, v in pairs(value) do result[k] = deepCopy(v, level + 1) end for k, v in pairs(value) do
result[k] = deepCopy(v, level + 1)
end
return result return result
end end
local function mergeRecurse(level, ...) local function mergeRecurse(level, ...)
if level > MERGE_DEPTH_LIMIT then error("table.deepMerge recursion too deep") end if level > MERGE_DEPTH_LIMIT then
error("table.deepMerge recursion too deep")
end
local result = {} local result = {}
local n = 0 local n = 0
for argi = 1, select('#', ...) do for argi = 1, select("#", ...) do
local tbl = select(argi, ...) local tbl = select(argi, ...)
if type(tbl) ~= "table" then if type(tbl) ~= "table" then
error("table.deepMerge: argument #" .. argi .. " is not a table (got " .. type(tbl) .. ")") error("table.deepMerge: argument #" .. argi .. " is not a table (got " .. type(tbl) .. ")")
@ -135,126 +214,235 @@ local function mergeRecurse(level, ...)
return result return result
end end
--- Recursively merge tables. Nested tables on both sides are merged; other values are overwritten. --- @brief Merge tables recursively into a new table. Sequence parts are appended in argument
--- The result shares no tables with the inputs (everything is deep-copied). --- order (like table.merge); for other keys, when both sides hold tables they are merged
--- recursively, otherwise the value from the later argument overwrites the earlier one.
--- The result shares no tables with the inputs (everything adopted is deep-copied).
--- Errors if an argument is not a table, or when nesting exceeds a depth limit.
---
--- Metatables are not copied.
--- @param ... table: Tables to merge
--- @return table: Product of the operation (new table)
function table.deepMerge(...) function table.deepMerge(...)
return mergeRecurse(1, ...) return mergeRecurse(1, ...)
end end
--- Deep-copy a table at all levels (excluding metatables). --- @brief Make a deep copy of a table
---
--- Raises an error if the value is not a table
---
--- @param value table: Table to copy
--- @return table: Clone of the table at all levels, excluding metatables
function table.deepCopy(value) function table.deepCopy(value)
if type(value) ~= "table" then if type(value) ~= "table" then
error("table.deepCopy: argument is not a table (got " .. type(value) .. ")") error("table.deepCopy: argument is not a table (got " .. type(value) .. ")")
end end
return deepCopy(value, 1) return deepCopy(value, 1)
end end
--- Reduce all values in a table to one using a function. Iteration order is unspecified. --- @brief: Reduces all values in a table to a single value using a reducer function & initial value. Iteration order is unspecified.
--- @param tbl table: Table to reduce
--- @param reducer function: The reducer function, e.g. function(acc, v) return acc + v end - this computes the sum of all values in the table
--- @param initialValue any: Starting value of the accumulator
--- @return any: Result of the reduction
function table.reduce(tbl, reducer, initialValue) function table.reduce(tbl, reducer, initialValue)
if type(tbl) ~= "table" then error("table.reduce: table argument is not a table (got " .. type(tbl) .. ")") end if type(tbl) ~= "table" then
if type(reducer) ~= "function" then error("table.reduce: reducer is not a function (got " .. type(reducer) .. ")") end error("table.reduce: table argument is not a table (got " .. type(tbl) .. ")")
end
if type(reducer) ~= "function" then
error("table.reduce: reducer is not a function (got " .. type(reducer) .. ")")
end
local accumulator = initialValue local accumulator = initialValue
for _, v in pairs(tbl) do accumulator = reducer(accumulator, v) end for _, v in pairs(tbl) do
accumulator = reducer(accumulator, v)
end
return accumulator return accumulator
end end
--- Reduce a sequence table to one value, iterating in order. --- @brief: Reduces a numbered table to a single value using a reducer function & initial value, iterating the sequence part in order. Other keys are ignored.
--- @param tbl table: Numbered table to reduce
--- @param reducer function: The reducer function, e.g. function(acc, v) return acc .. v end - this concatenates all values in order
--- @param initialValue any: Starting value of the accumulator
--- @return any: Result of the reduction
function table.ireduce(tbl, reducer, initialValue) function table.ireduce(tbl, reducer, initialValue)
if type(tbl) ~= "table" then error("table.ireduce: table argument is not a table (got " .. type(tbl) .. ")") end if type(tbl) ~= "table" then
if type(reducer) ~= "function" then error("table.ireduce: reducer is not a function (got " .. type(reducer) .. ")") end error("table.ireduce: table argument is not a table (got " .. type(tbl) .. ")")
end
if type(reducer) ~= "function" then
error("table.ireduce: reducer is not a function (got " .. type(reducer) .. ")")
end
local accumulator = initialValue local accumulator = initialValue
for _, v in ipairs(tbl) do accumulator = reducer(accumulator, v) end for _, v in ipairs(tbl) do
accumulator = reducer(accumulator, v)
end
return accumulator return accumulator
end end
--- Return the minimum value in a table. Returns nil for an empty table. NaN propagates. --- @brief: Get the lowest of all values in a table (any table, all values are visited).
--- Returns nil if the table is empty. If any value is NaN, the result is NaN.
--- @param tbl table: Table to search
--- @return any: The lowest value, or nil if the table is empty
function table.min(tbl) function table.min(tbl)
if type(tbl) ~= "table" then error("table.min: argument is not a table (got " .. type(tbl) .. ")") end if type(tbl) ~= "table" then
error("table.min: argument is not a table (got " .. type(tbl) .. ")")
end
local min local min
for _, val in pairs(tbl) do for _, val in pairs(tbl) do
if val ~= val then return val end if val ~= val then
if min == nil or val < min then min = val end return val -- NaN propagates
end
if min == nil or val < min then
min = val
end
end end
return min return min
end end
--- Return the maximum value in a table. Returns nil for an empty table. NaN propagates. --- @brief: Get the highest of all values in a table (any table, all values are visited).
--- Returns nil if the table is empty. If any value is NaN, the result is NaN.
--- @param tbl table: Table to search
--- @return any: The highest value, or nil if the table is empty
function table.max(tbl) function table.max(tbl)
if type(tbl) ~= "table" then error("table.max: argument is not a table (got " .. type(tbl) .. ")") end if type(tbl) ~= "table" then
error("table.max: argument is not a table (got " .. type(tbl) .. ")")
end
local max local max
for _, val in pairs(tbl) do for _, val in pairs(tbl) do
if val ~= val then return val end if val ~= val then
if max == nil or val > max then max = val end return val -- NaN propagates
end
if max == nil or val > max then
max = val
end
end end
return max return max
end end
--- Return the arithmetic mean of all values. Returns nil for an empty table. NaN propagates. --- @brief: Get the mean of all values in a table (any table, all values are visited).
--- Returns nil if the table is empty. If any value is NaN, the result is NaN.
--- @param tbl table: Table to average
--- @return number|nil: Mean of the values, or nil if the table is empty
function table.mean(tbl) function table.mean(tbl)
if type(tbl) ~= "table" then error("table.mean: argument is not a table (got " .. type(tbl) .. ")") end if type(tbl) ~= "table" then
error("table.mean: argument is not a table (got " .. type(tbl) .. ")")
end
local sum local sum
local count = 0 local count = 0
for _, val in pairs(tbl) do for _, val in pairs(tbl) do
count = count + 1 count = count + 1
sum = sum and sum + val or val if sum == nil then
sum = val
else
sum = sum + val
end
end end
return count > 0 and sum / count or nil if count == 0 then
return nil
end
return sum / count
end end
--- Return the sum of all values. Returns 0 for an empty table. NaN propagates. --- @brief: Get the sum of all values in a table (any table, all values are visited).
--- Returns 0 if the table is empty. If any value is NaN, the result is NaN.
--- @param tbl table: Table to sum
--- @return number: Sum of the values, 0 for an empty table
function table.sum(tbl) function table.sum(tbl)
if type(tbl) ~= "table" then error("table.sum: argument is not a table (got " .. type(tbl) .. ")") end if type(tbl) ~= "table" then
error("table.sum: argument is not a table (got " .. type(tbl) .. ")")
end
local sum = 0 local sum = 0
for _, val in pairs(tbl) do sum = sum + val end for _, val in pairs(tbl) do
sum = sum + val
end
return sum return sum
end end
--- Return an array of all keys. Order is unspecified. --- @brief: Get an array of all keys in the table. Order is unspecified.
--- @param tbl table: Table to take the keys from
--- @return table: New numbered table with all keys
function table.keys(tbl) function table.keys(tbl)
if type(tbl) ~= "table" then error("table.keys: argument is not a table (got " .. type(tbl) .. ")") end if type(tbl) ~= "table" then
error("table.keys: argument is not a table (got " .. type(tbl) .. ")")
end
local result = {} local result = {}
for k in pairs(tbl) do result[#result + 1] = k end for k, _ in pairs(tbl) do
result[#result + 1] = k
end
return result return result
end end
--- Return an array of all values. Order is unspecified. --- @brief: Get an array of all values in the table. Order is unspecified.
--- @param tbl table: Table to take the values from
--- @return table: New numbered table with all values
function table.values(tbl) function table.values(tbl)
if type(tbl) ~= "table" then error("table.values: argument is not a table (got " .. type(tbl) .. ")") end if type(tbl) ~= "table" then
error("table.values: argument is not a table (got " .. type(tbl) .. ")")
end
local result = {} local result = {}
for _, v in pairs(tbl) do result[#result + 1] = v end for _, v in pairs(tbl) do
result[#result + 1] = v
end
return result return result
end end
--- Return true if the table contains the given value (compared with ==). --- @brief: Check if a table contains a specific value.
--- @param tbl table: Table to check
--- @param value any: Value to search for (compared with ==)
--- @return boolean: True if the table contains the given element
function table.contains(tbl, value) function table.contains(tbl, value)
if type(tbl) ~= "table" then error("table.contains: argument is not a table (got " .. type(tbl) .. ")") end if type(tbl) ~= "table" then
error("table.contains: argument is not a table (got " .. type(tbl) .. ")")
end
for _, v in pairs(tbl) do for _, v in pairs(tbl) do
if v == value then return true end if v == value then
return true
end
end end
return false return false
end end
--- Find a value matching a predicate. Returns (value, key) or (nil, nil) if not found. --- @brief: Find a table element that matches a predicate. Search order is unspecified
--- Check the returned key for nil to distinguish a stored false from "not found". --- (sequence part is in practice visited first, in order).
--- @param tbl table: Table to check
--- @param predicate function: Checker function
--- @return any, any: Matching value, and its index (value, key); If not found, returns nil, nil.
--- Check the key for nil to distinguish a stored false value from "not found".
function table.find(tbl, predicate) function table.find(tbl, predicate)
if type(tbl) ~= "table" then error("table.find: table argument is not a table (got " .. type(tbl) .. ")") end if type(tbl) ~= "table" then
if type(predicate) ~= "function" then error("table.find: predicate is not a function (got " .. type(predicate) .. ")") end error("table.find: table argument is not a table (got " .. type(tbl) .. ")")
end
if type(predicate) ~= "function" then
error("table.find: predicate is not a function (got " .. type(predicate) .. ")")
end
for k, v in pairs(tbl) do for k, v in pairs(tbl) do
if predicate(v) then return v, k end if predicate(v) then
return v, k
end
end end
return nil, nil return nil, nil
end end
--- Return true if the table has no elements. --- @brief: Check if a table is empty (has no elements).
--- @param tbl table: Table to check
--- @return boolean: True if empty table
function table.isEmpty(tbl) function table.isEmpty(tbl)
if type(tbl) ~= "table" then error("table.isEmpty: argument is not a table (got " .. type(tbl) .. ")") end if type(tbl) ~= "table" then
error("table.isEmpty: argument is not a table (got " .. type(tbl) .. ")")
end
return next(tbl) == nil return next(tbl) == nil
end end
--- Return a new array with elements in reverse order. --- @brief: Reverse an array. Returns a new array with elements in reverse order.
--- @param tbl table: Table to reverse
--- @return table: New table with reversed order
function table.reverse(tbl) function table.reverse(tbl)
if type(tbl) ~= "table" then error("table.reverse: argument is not a table (got " .. type(tbl) .. ")") end if type(tbl) ~= "table" then
error("table.reverse: argument is not a table (got " .. type(tbl) .. ")")
end
local result = {} local result = {}
for i = #tbl, 1, -1 do result[#result + 1] = tbl[i] end for i = #tbl, 1, -1 do
result[#result + 1] = tbl[i]
end
return result return result
end end

@ -1,81 +1,103 @@
-- Lua-side of the utils module. -- Lua-implemented part of the utils module, extending the global utils table.
-- utils.NULL, utils.isNull, utils.toJSON, utils.fromJSON are provided by Rust before this runs. -- The native parts of the module (dump, toJSON, fromJSON, NULL, isNull)
-- are declared in Rust before this file is loaded.
-- Substituted when a callback throws a nil error value (e.g. plain `error()`),
-- so that the returned err is always non-nil on failure.
local NIL_ERROR_PLACEHOLDER = "unspecified error" local NIL_ERROR_PLACEHOLDER = "unspecified error"
-- Pretty-print any Lua value as a string. --- @brief Call a function, capturing any error - like pcall, but with Go-style returns.
local function dumpValue(val, depth, seen) ---
if depth > 20 then return "..." end --- local value, err = utils.try(callback)
local t = type(val) --- local value, err = utils.try(callback, arg1, arg2) -- pcall-style shorthand
if t == "nil" then --- if err then
return "nil" --- print(err)
elseif t == "boolean" or t == "number" then --- else
return tostring(val) --- -- handle value
elseif t == "string" then --- end
return string.format("%q", val) ---
elseif t == "table" then --- - err is nil if the callback succeeded, otherwise the error value
if utils.isNull(val) then return "null" end --- - value is the callback's first return value (nil on error)
if seen[val] then return "<circular>" end --- - extra arguments are passed to the callback, like with pcall
seen[val] = true ---
local parts = {} --- This is less confusing than pcall when the return value is actually needed.
local len = #val --- The thrown error value is passed through as-is, so a table thrown with
if len > 0 then --- error({code = 42}) stays a table. The downside is that the result can't be
for i = 1, len do --- used directly as a condition in "if" or "while" - simply use pcall there.
parts[i] = dumpValue(val[i], depth + 1, seen) ---
end --- @param callback function: Function to call
else --- @param ... any: Arguments passed to the callback
for k, v in pairs(val) do --- @return any, any: The callback's first return value and nil, or nil and the error
local key = type(k) == "string" and k or ("[" .. tostring(k) .. "]")
parts[#parts + 1] = key .. " = " .. dumpValue(v, depth + 1, seen)
end
end
seen[val] = nil
if #parts == 0 then return "{}" end
return "{ " .. table.concat(parts, ", ") .. " }"
else
return "<" .. t .. ">"
end
end
--- Pretty-print any Lua value to a string.
function utils.dump(val)
return dumpValue(val, 0, {})
end
--- Call a function, capturing any error. Returns (result, nil) on success or (nil, err) on failure.
--- Extra arguments are forwarded to the callback, like pcall.
function utils.try(callback, ...) function utils.try(callback, ...)
if type(callback) ~= "function" then if type(callback) ~= "function" then
error("utils.try: callback is not a function (got " .. type(callback) .. ")") error("utils.try: callback is not a function (got " .. type(callback) .. ")")
end end
local ok, result = pcall(callback, ...) local ok, result = pcall(callback, ...)
if ok then return result, nil end if ok then
if result == nil then result = NIL_ERROR_PLACEHOLDER end return result, nil
end
if result == nil then
result = NIL_ERROR_PLACEHOLDER
end
return nil, result return nil, result
end end
local TRYN_MAX_COUNT = 64 local TRYN_MAX_COUNT = 64
--- Like utils.try but for functions that return N values. --- @brief Call a function, capturing any error - like utils.try, but for N return values.
--- Returns (v1, ..., vN, nil) on success or (nil, ..., nil, err) on failure. ---
--- local value1, value2, err = utils.tryn(2, callback)
--- local value1, value2, err = utils.tryn(2, callback, arg1, arg2) -- pcall-style shorthand
--- if err then
--- print(err)
--- else
--- -- handle (value1, value2)
--- end
---
--- - err is nil if the callback succeeded, otherwise the error value
--- - values are the callback's return values, padded with nils or truncated to exactly N
--- - extra arguments are passed to the callback, like with pcall
---
--- This is less confusing than pcall for multiple return values, where the error
--- and the first value share a slot:
---
--- local status, errorOrValue1, value2 = pcall(callback)
---
--- @param expectedCount number: How many return values to pass through (integer, 0 to 64)
--- @param callback function: Function to call
--- @param ... any: Arguments passed to the callback
--- @return any ...: expectedCount values followed by the error or nil
function utils.tryn(expectedCount, callback, ...) function utils.tryn(expectedCount, callback, ...)
local count = math.tointeger(expectedCount) local count = math.tointeger(expectedCount)
if count == nil then error("utils.tryn: expectedCount must be an integer number") end if count == nil then
error("utils.tryn: expectedCount must be an integer number")
end
expectedCount = count expectedCount = count
if expectedCount < 0 then error("utils.tryn: expectedCount must not be negative") end if expectedCount < 0 then
error("utils.tryn: expectedCount must not be negative")
end
if expectedCount > TRYN_MAX_COUNT then if expectedCount > TRYN_MAX_COUNT then
error("utils.tryn: at most " .. TRYN_MAX_COUNT .. " return values are supported") error("utils.tryn: at most " .. TRYN_MAX_COUNT .. " return values are supported")
end end
if type(callback) ~= "function" then if type(callback) ~= "function" then
error("utils.tryn: callback is not a function (got " .. type(callback) .. ")") error("utils.tryn: callback is not a function (got " .. type(callback) .. ")")
end end
local results = table.pack(pcall(callback, ...)) local results = table.pack(pcall(callback, ...))
if results[1] then if results[1] then
-- Success: pass through values 2..expectedCount+1 (truncating or nil-padding),
-- with nil in the error slot. The explicit assignment overwrites a possible
-- extra return value so it cannot leak into the error slot.
results[expectedCount + 2] = nil results[expectedCount + 2] = nil
return table.unpack(results, 2, expectedCount + 2) return table.unpack(results, 2, expectedCount + 2)
end end
local err = results[2] local err = results[2]
if err == nil then err = NIL_ERROR_PLACEHOLDER end if err == nil then
err = NIL_ERROR_PLACEHOLDER
end
local out = {} local out = {}
out[expectedCount + 1] = err out[expectedCount + 1] = err
return table.unpack(out, 1, expectedCount + 1) return table.unpack(out, 1, expectedCount + 1)

@ -15,9 +15,13 @@ print("sum =", table.sum(t)) -- 14
print("min =", table.min(t)) -- 1 print("min =", table.min(t)) -- 1
print("max =", table.max(t)) -- 5 print("max =", table.max(t)) -- 5
print("mean =", table.mean(t)) -- 2.8 print("mean =", table.mean(t)) -- 2.8
local evens = table.ifilter(t, function(v) return v % 2 == 0 end) local evens = table.ifilter(t, function(v)
return v % 2 == 0
end)
print("evens =", table.concat(evens, ",")) -- 4 print("evens =", table.concat(evens, ",")) -- 4
local doubled = table.map({1,2,3}, function(v) return v * 2 end) local doubled = table.map({ 1, 2, 3 }, function(v)
return v * 2
end)
print("doubled=", table.concat(doubled, ",")) -- 2,4,6 print("doubled=", table.concat(doubled, ",")) -- 2,4,6
print("deepCopy ok:", table.deepCopy({ x = { y = 1 } }).x.y == 1) print("deepCopy ok:", table.deepCopy({ x = { y = 1 } }).x.y == 1)
print("merged =", table.concat(table.merge({ 1, 2 }, { 3, 4 }), ",")) -- 1,2,3,4 print("merged =", table.concat(table.merge({ 1, 2 }, { 3, 4 }), ",")) -- 1,2,3,4
@ -34,9 +38,13 @@ print("toJSON =", json)
local parsed = utils.fromJSON(json) local parsed = utils.fromJSON(json)
print("fromJSON name=", parsed.name, "age=", parsed.age) print("fromJSON name=", parsed.name, "age=", parsed.age)
local v, err = utils.try(function() return 42 end) local v, err = utils.try(function()
return 42
end)
print("try ok: v=", v, "err=", err) print("try ok: v=", v, "err=", err)
local v2, err2 = utils.try(function() error("oops") end) local v2, err2 = utils.try(function()
error("oops")
end)
print("try err: v=", v2, "err ~= nil:", err2 ~= nil) print("try err: v=", v2, "err ~= nil:", err2 ~= nil)
print("\n=== os ===") print("\n=== os ===")

@ -2,8 +2,10 @@
local function assert_eq(actual, expected, msg) local function assert_eq(actual, expected, msg)
if actual ~= expected then if actual ~= expected then
error(string.format("%s: expected %s, got %s", error(
msg or "assertion failed", tostring(expected), tostring(actual)), 2) string.format("%s: expected %s, got %s", msg or "assertion failed", tostring(expected), tostring(actual)),
2
)
end end
end end
@ -105,7 +107,9 @@ assert_eq(mixed_ok, false, "mixed positional/named params error")
print("=== close ===") print("=== close ===")
con:close() con:close()
local closed_ok = pcall(function() con:query("SELECT 1") end) local closed_ok = pcall(function()
con:query("SELECT 1")
end)
assert_eq(closed_ok, false, "use after close errors") assert_eq(closed_ok, false, "use after close errors")
print("\nAll sqlite checks passed.") print("\nAll sqlite checks passed.")

@ -0,0 +1,313 @@
//! Tests for the async parts of the stdlib: os.sleep, task.join and sqlite.
//! These have no flowbox-rt counterpart and need a tokio runtime, so they live
//! apart from the ported sync tests.
use std::time::{Duration, Instant};
use super::lua;
#[tokio::test]
async fn test_os_sleep_actually_sleeps() {
let lua = lua();
let start = Instant::now();
lua.load(r#"os.sleep(0.1)"#).exec_async().await.unwrap();
let elapsed = start.elapsed();
assert!(elapsed >= Duration::from_millis(95), "slept only {elapsed:?}");
assert!(elapsed < Duration::from_millis(500), "slept too long: {elapsed:?}");
}
#[tokio::test]
async fn test_os_sleep_invalid_duration() {
let lua = lua();
// Negative, NaN and infinite durations must raise a Lua error, not panic the host
for src in ["os.sleep(-1)", "os.sleep(0/0)", "os.sleep(math.huge)"] {
let err = lua.load(src).exec_async().await.unwrap_err();
assert!(err.to_string().contains("os.sleep"), "{src}: {err}");
}
}
#[tokio::test]
async fn test_os_sleep_rejects_huge_finite_duration() {
let lua = lua();
// A huge-but-finite duration must be rejected promptly, not parked forever
// (which would read as the runtime hanging). The test itself would hang if
// this regressed, so tokio's test timeout / the assertion guards it.
let err = lua.load(r#"os.sleep(1e18)"#).exec_async().await.unwrap_err();
assert!(err.to_string().contains("os.sleep"), "{err}");
// A duration within the cap is accepted (0 sleeps instantly).
lua.load(r#"os.sleep(0)"#).exec_async().await.unwrap();
}
#[tokio::test]
async fn test_task_join_returns_results_positionally() {
let lua = lua();
let (a, b, c): (i64, String, bool) = lua
.load(
r#"
return task.join(
function() return 1 end,
function() os.sleep(0.01) return "two" end,
function() return true end
)
"#,
)
.eval_async()
.await
.unwrap();
assert_eq!(a, 1);
assert_eq!(b, "two");
assert!(c);
}
#[tokio::test]
async fn test_task_join_runs_concurrently() {
let lua = lua();
let start = Instant::now();
lua.load(
r#"
task.join(
function() os.sleep(0.15) end,
function() os.sleep(0.1) end,
function() os.sleep(0.05) end
)
"#,
)
.exec_async()
.await
.unwrap();
let elapsed = start.elapsed();
// Sequential execution would take ~0.3s; overlapped, the slowest task dominates.
assert!(elapsed >= Duration::from_millis(140), "finished too fast: {elapsed:?}");
assert!(elapsed < Duration::from_millis(280), "tasks did not overlap: {elapsed:?}");
}
#[tokio::test]
async fn test_task_join_rejects_non_function_argument() {
let lua = lua();
// A non-function argument gives a prefixed, positioned error, not mlua's
// bare "error converting Lua integer to function".
let err = lua
.load(r#"task.join(function() end, 42)"#)
.exec_async()
.await
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("task.join: argument #2 must be a function"), "{msg}");
assert!(msg.contains("integer"), "{msg}");
}
#[tokio::test]
async fn test_os_sleep_rejects_non_number() {
let lua = lua();
let err = lua.load(r#"os.sleep("soon")"#).exec_async().await.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("os.sleep: duration must be a number"), "{msg}");
}
#[tokio::test]
async fn test_task_join_propagates_errors() {
let lua = lua();
let err = lua
.load(r#"task.join(function() error("boom") end, function() return 1 end)"#)
.exec_async()
.await
.unwrap_err();
assert!(err.to_string().contains("boom"));
}
#[tokio::test]
async fn test_sqlite_in_memory_roundtrip() {
let lua = lua();
let (name, id, count): (String, i64, i64) = lua
.load(
r#"
local db = sqlite.connect(":memory:")
db:execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
db:execute("INSERT INTO t (name) VALUES (?)", {"alice"})
db:execute("INSERT INTO t (name) VALUES (?)", {"bob"})
local id = db:lastInsertRowid()
local row = db:queryOne("SELECT name FROM t WHERE id = ?", {1})
local rows = db:query("SELECT * FROM t")
db:close()
return row.name, id, #rows
"#,
)
.eval_async()
.await
.unwrap();
assert_eq!(name, "alice");
assert_eq!(id, 2);
assert_eq!(count, 2);
}
#[tokio::test]
async fn test_sqlite_binds_non_utf8_as_blob() {
let lua = lua();
// A non-UTF-8 / NUL-containing Lua string must bind and round-trip intact
// (as a BLOB) rather than failing the UTF-8 conversion.
let ok: bool = lua
.load(
r#"
local db = sqlite.connect(":memory:")
db:execute("CREATE TABLE t (id INTEGER PRIMARY KEY, data)")
local blob = "\255\0\254bin"
db:execute("INSERT INTO t (data) VALUES (?)", {blob})
local got = db:queryOne("SELECT data FROM t WHERE id = 1").data
return got == blob
"#,
)
.eval_async()
.await
.unwrap();
assert!(ok);
}
#[tokio::test]
async fn test_sqlite_rejects_empty_and_multi_statements() {
let lua = lua();
let err = lua
.load(r#"local db = sqlite.connect(":memory:"); return db:execute(" ")"#)
.eval_async::<mlua::Value>()
.await
.unwrap_err();
assert!(err.to_string().contains("no SQL statement"), "{err}");
// A comment-only statement has no runnable SQL either.
let err = lua
.load(r#"local db = sqlite.connect(":memory:"); return db:query("-- only a comment")"#)
.eval_async::<mlua::Value>()
.await
.unwrap_err();
assert!(err.to_string().contains("no SQL statement"), "{err}");
let err = lua
.load(
r#"
local db = sqlite.connect(":memory:")
db:execute("CREATE TABLE t (x)")
return db:execute("INSERT INTO t VALUES (1); INSERT INTO t VALUES (2)")
"#,
)
.eval_async::<mlua::Value>()
.await
.unwrap_err();
assert!(err.to_string().contains("single SQL statement"), "{err}");
}
#[tokio::test]
async fn test_sqlite_execute_batch_runs_all_statements() {
let lua = lua();
let count: i64 = lua
.load(
r#"
local db = sqlite.connect(":memory:")
db:executeBatch([[
CREATE TABLE t (x);
INSERT INTO t VALUES (1);
INSERT INTO t VALUES (2);
INSERT INTO t VALUES (3);
]])
return db:queryOne("SELECT COUNT(*) AS n FROM t").n
"#,
)
.eval_async()
.await
.unwrap();
assert_eq!(count, 3);
}
#[tokio::test]
async fn test_sqlite_transaction_passes_through_return_values() {
let lua = lua();
let (a, b): (i64, String) = lua
.load(
r#"
local db = sqlite.connect(":memory:")
db:execute("CREATE TABLE t (x)")
return db:transaction(function() return 7, "eight" end)
"#,
)
.eval_async()
.await
.unwrap();
assert_eq!(a, 7);
assert_eq!(b, "eight");
}
#[tokio::test]
async fn test_sqlite_transaction_does_not_mask_original_error() {
let lua = lua();
// If the body fails AND rollback then also fails (here: the connection is
// closed inside the body), the caller must still see the body's error.
let err = lua
.load(
r#"
local db = sqlite.connect(":memory:")
db:execute("CREATE TABLE t (x)")
db:transaction(function()
db:close()
error("ORIGINAL ERROR")
end)
"#,
)
.exec_async()
.await
.unwrap_err();
assert!(err.to_string().contains("ORIGINAL ERROR"), "{err}");
}
#[tokio::test]
async fn test_sqlite_transaction_commit_and_rollback() {
let lua = lua();
let (after_commit, after_rollback): (i64, i64) = lua
.load(
r#"
local db = sqlite.connect(":memory:")
db:execute("CREATE TABLE t (x)")
db:transaction(function()
db:execute("INSERT INTO t VALUES (1)")
end)
local committed = db:queryOne("SELECT COUNT(*) AS n FROM t").n
local ok, err = pcall(function()
db:transaction(function()
db:execute("INSERT INTO t VALUES (2)")
error("nope")
end)
end)
assert(not ok)
assert(tostring(err):find("nope"))
local rolled_back = db:queryOne("SELECT COUNT(*) AS n FROM t").n
return committed, rolled_back
"#,
)
.eval_async()
.await
.unwrap();
assert_eq!(after_commit, 1);
// The failed transaction's insert must have been rolled back.
assert_eq!(after_rollback, 1);
}

@ -0,0 +1,226 @@
//! Core environment tests: basic Lua evaluation, sandbox verification, and
//! checks that the stdlib extends rather than replaces the standard modules.
//!
//! Ported from flowbox-rt's fb_lua core_tests, with sandbox-verification
//! tests added for this project's sandbox (see src/sandbox.rs).
use super::assert_eq_f64;
/// Smoke test for the environment setup
#[test]
fn test_call_lua_function() {
let lua = super::lua();
let chunk: mlua::Function = lua
.load(
r#"
function(a, b)
return a + b
end
"#,
)
.eval()
.unwrap();
let res: i32 = chunk.call((10, 20)).unwrap();
assert_eq!(res, 30);
}
/// dofile, loadfile and load (raw bytecode loading) are removed from the sandbox.
#[test]
fn test_sandbox_chunk_loaders_removed() {
let lua = super::lua();
for name in ["dofile", "loadfile", "load"] {
let is_nil: bool = lua.load(format!("return {name} == nil")).eval().unwrap();
assert!(is_nil, "global `{name}` should be nil in the sandbox");
}
}
/// string.dump (bytecode producer, the counterpart of load) is removed.
#[test]
fn test_sandbox_string_dump_removed() {
let lua = super::lua();
let is_nil: bool = lua.load(r#"return string.dump == nil"#).eval().unwrap();
assert!(is_nil, "string.dump should be nil in the sandbox");
}
/// io, package and debug libraries are not loaded at all.
#[test]
fn test_sandbox_io_package_debug_not_loaded() {
let lua = super::lua();
for name in ["io", "package", "debug"] {
let is_nil: bool = lua.load(format!("return {name} == nil")).eval().unwrap();
assert!(is_nil, "global `{name}` should be nil in the sandbox");
}
}
/// collectgarbage only allows the "count" option; everything else raises
/// an error mentioning the sandbox.
#[test]
fn test_sandbox_collectgarbage_count_only() {
let lua = super::lua();
let count: f64 = lua.load(r#"return collectgarbage("count")"#).eval().unwrap();
assert!(count > 0.0, "collectgarbage(\"count\") should report heap KB, got {count}");
let err = lua.load(r#"return collectgarbage()"#).exec().unwrap_err();
assert!(err.to_string().contains("sandboxed"), "unexpected error: {err}");
let err = lua.load(r#"return collectgarbage("collect")"#).exec().unwrap_err();
assert!(err.to_string().contains("sandboxed"), "unexpected error: {err}");
}
/// The os table only keeps the safe time functions plus our extensions.
#[test]
fn test_sandbox_os_dangerous_functions_removed() {
let lua = super::lua();
let os_table = lua.globals().get::<mlua::Table>("os").unwrap();
for name in ["execute", "exit", "getenv", "remove", "rename", "setlocale", "tmpname"] {
assert!(
!os_table.contains_key(name).unwrap(),
"os.{name} should be removed from the sandbox"
);
}
for name in ["date", "difftime", "time", "clock", "microtime", "sleep"] {
assert!(
os_table.contains_key(name).unwrap(),
"os.{name} should be present in the sandbox"
);
}
}
/// Verify that the math table was extended with custom functions,
/// not replaced - standard Lua math functions must still exist.
#[test]
fn test_math_table_extended_not_replaced() {
let lua = super::lua();
// Test standard math functions still work
let result: f64 = lua.load(r#"return math.floor(3.7)"#).eval().unwrap();
assert_eq_f64!(result, 3.0);
let result: f64 = lua.load(r#"return math.ceil(3.2)"#).eval().unwrap();
assert_eq_f64!(result, 4.0);
let result: f64 = lua.load(r#"return math.abs(-5)"#).eval().unwrap();
assert_eq_f64!(result, 5.0);
let result: f64 = lua.load(r#"return math.sqrt(16)"#).eval().unwrap();
assert_eq_f64!(result, 4.0);
let result: f64 = lua.load(r#"return math.sin(0)"#).eval().unwrap();
assert_eq_f64!(result, 0.0);
let result: f64 = lua.load(r#"return math.cos(0)"#).eval().unwrap();
assert_eq_f64!(result, 1.0);
let result: f64 = lua.load(r#"return math.max(1, 5, 3)"#).eval().unwrap();
assert_eq_f64!(result, 5.0);
let result: f64 = lua.load(r#"return math.min(1, 5, 3)"#).eval().unwrap();
assert_eq_f64!(result, 1.0);
// Verify math.pi constant exists
let result: f64 = lua.load(r#"return math.pi"#).eval().unwrap();
assert!(result > 3.14 && result < 3.15);
// Verify our custom functions are also present
let result: i64 = lua.load(r#"return math.round(3.7)"#).eval().unwrap();
assert_eq!(result, 4);
let result: f64 = lua.load(r#"return math.round(3.1415, 2)"#).eval().unwrap();
assert_eq_f64!(result, 3.14);
let result: i64 = lua.load(r#"return math.clamp(50, 0, 100)"#).eval().unwrap();
assert_eq!(result, 50);
}
/// Verify that the table module was extended with custom functions,
/// not replaced - standard Lua table functions must still exist.
#[test]
fn test_table_module_extended_not_replaced() {
let lua = super::lua();
// Test standard table functions still work
// table.insert
let result: String = lua
.load(
r#"
local t = {1, 2, 3}
table.insert(t, 4)
return utils.toJSON(t)
"#,
)
.eval()
.unwrap();
assert_eq!(result, "[1,2,3,4]");
// table.remove
let result: String = lua
.load(
r#"
local t = {1, 2, 3, 4}
table.remove(t, 2)
return utils.toJSON(t)
"#,
)
.eval()
.unwrap();
assert_eq!(result, "[1,3,4]");
// table.concat
let result: String = lua.load(r#"return table.concat({"a", "b", "c"}, "-")"#).eval().unwrap();
assert_eq!(result, "a-b-c");
// table.sort
let result: String = lua
.load(
r#"
local t = {3, 1, 4, 1, 5, 9}
table.sort(t)
return utils.toJSON(t)
"#,
)
.eval()
.unwrap();
assert_eq!(result, "[1,1,3,4,5,9]");
// table.unpack
let result: i64 = lua
.load(
r#"
local t = {10, 20, 30}
local a, b, c = table.unpack(t)
return a + b + c
"#,
)
.eval()
.unwrap();
assert_eq!(result, 60);
// Verify our custom functions are also present
let _: () = lua
.load(
r#"
local ro = table.readonly({x = 1})
assert(ro.x == 1)
"#,
)
.exec()
.unwrap();
let result: String = lua
.load(
r#"
local merged = table.merge({a = 1}, {b = 2})
return utils.toJSON(merged)
"#,
)
.eval()
.unwrap();
assert!(result.contains("\"a\":1") && result.contains("\"b\":2"));
}

@ -0,0 +1,398 @@
//! Tests for the math stdlib extensions (lua/stdlib/math.lua).
use super::assert_eq_f64;
use mlua::prelude::LuaValue;
#[test]
fn test_clamp() {
let lua = super::lua();
// Simple, integers
let result: i64 = lua.load(r#"return math.clamp(3, 10, 20)"#).eval().unwrap();
assert_eq!(result, 10); // low
let result: i64 = lua.load(r#"return math.clamp(3, nil, 20)"#).eval().unwrap();
assert_eq!(result, 3); // low-unbounded
let result: i64 = lua.load(r#"return math.clamp(15, 10, 20)"#).eval().unwrap();
assert_eq!(result, 15); // inside
let result: i64 = lua.load(r#"return math.clamp(25, 10, 20)"#).eval().unwrap();
assert_eq!(result, 20); // high
let result: i64 = lua.load(r#"return math.clamp(25, 10, nil)"#).eval().unwrap();
assert_eq!(result, 25); // high-unbounded
let result: i64 = lua.load(r#"return math.clamp(999, nil, nil)"#).eval().unwrap();
assert_eq!(result, 999); // unbounded
// Floats
let result: f64 = lua.load(r#"return math.clamp(3.5, 10.5, 20.5)"#).eval().unwrap();
assert_eq_f64!(result, 10.5); // low
let result: f64 = lua.load(r#"return math.clamp(3.5, nil, 20.5)"#).eval().unwrap();
assert_eq_f64!(result, 3.5); // low-unbounded
let result: f64 = lua.load(r#"return math.clamp(15.5, 10.5, 20.5)"#).eval().unwrap();
assert_eq_f64!(result, 15.5); // inside
let result: f64 = lua.load(r#"return math.clamp(25.5, 10.5, 20.5)"#).eval().unwrap();
assert_eq_f64!(result, 20.5); // high
let result: f64 = lua.load(r#"return math.clamp(25.5, 10.5, nil)"#).eval().unwrap();
assert_eq_f64!(result, 25.5); // high-unbounded
let result: f64 = lua.load(r#"return math.clamp(999.9, nil, nil)"#).eval().unwrap();
assert_eq_f64!(result, 999.9); // unbounded
// Check some negative numbers
let result: i64 = lua.load(r#"return math.clamp(3, -10, 20)"#).eval().unwrap();
assert_eq!(result, 3); // low
let result: i64 = lua.load(r#"return math.clamp(-15, -10, 20)"#).eval().unwrap();
assert_eq!(result, -10); // inside
let result: i64 = lua.load(r#"return math.clamp(-25, -10, 20)"#).eval().unwrap();
assert_eq!(result, -10); // high
let result: i64 = lua.load(r#"return math.clamp(25, -10, 20)"#).eval().unwrap();
assert_eq!(result, 20); // high
assert!(lua.load(r#"return math.clamp(nil, 1, 2)"#).eval::<LuaValue>().is_err());
assert!(lua.load(r#"return math.clamp(1, "1", 2)"#).eval::<LuaValue>().is_err());
assert!(lua.load(r#"return math.clamp(1, "foo", 2)"#).eval::<LuaValue>().is_err());
assert!(lua.load(r#"return math.clamp(1, 1, "bar")"#).eval::<LuaValue>().is_err());
assert!(lua.load(r#"return math.clamp(1, 1, "2")"#).eval::<LuaValue>().is_err());
// Crossed bounds (min > max) is a programming error, not a valid interval
let err = lua.load(r#"return math.clamp(5, 10, 0)"#).exec().unwrap_err();
assert!(err.to_string().contains("greater than"));
}
#[test]
fn test_round() {
let lua = super::lua();
// Round positive numbers
let result: i64 = lua.load(r#"return math.round(3.4)"#).eval().unwrap();
assert_eq!(result, 3);
let result: i64 = lua.load(r#"return math.round(3.5)"#).eval().unwrap();
assert_eq!(result, 4);
let result: i64 = lua.load(r#"return math.round(3.9)"#).eval().unwrap();
assert_eq!(result, 4);
// Round negative numbers
let result: i64 = lua.load(r#"return math.round(-3.4)"#).eval().unwrap();
assert_eq!(result, -3);
let result: i64 = lua.load(r#"return math.round(-3.5)"#).eval().unwrap();
assert_eq!(result, -4);
// Round whole numbers (should stay the same)
let result: i64 = lua.load(r#"return math.round(5)"#).eval().unwrap();
assert_eq!(result, 5);
let result: i64 = lua.load(r#"return math.round(0)"#).eval().unwrap();
assert_eq!(result, 0);
// Round with a non-number should error
let err = lua.load(r#"return math.round("not a number")"#).exec().unwrap_err();
assert!(err.to_string().contains("non-number"));
// NaN must error, not silently become 0
let err = lua.load(r#"return math.round(0/0)"#).exec().unwrap_err();
assert!(err.to_string().contains("finite"));
// Infinity must error (out of integer range)
assert!(lua.load(r#"return math.round(math.huge)"#).exec().is_err());
assert!(lua.load(r#"return math.round(-math.huge)"#).exec().is_err());
// Out of i64 range must error
assert!(lua.load(r#"return math.round(1e19)"#).exec().is_err());
assert!(lua.load(r#"return math.round(-1e19)"#).exec().is_err());
// Half away from zero at exactly +-0.5
let result: i64 = lua.load(r#"return math.round(0.5)"#).eval().unwrap();
assert_eq!(result, 1);
let result: i64 = lua.load(r#"return math.round(-0.5)"#).eval().unwrap();
assert_eq!(result, -1);
// The naive floor(value + 0.5) trap: 0.49999999999999994 + 0.5 == 1.0 in f64,
// but the nearest integer is 0
let result: i64 = lua.load(r#"return math.round(0.49999999999999994)"#).eval().unwrap();
assert_eq!(result, 0);
let result: i64 = lua.load(r#"return math.round(-0.49999999999999994)"#).eval().unwrap();
assert_eq!(result, 0);
// Integer-valued floats at the edge of f64 precision (2^53)
let result: i64 = lua.load(r#"return math.round(2.0^53 - 1.0)"#).eval().unwrap();
assert_eq!(result, 9007199254740991);
let result: i64 = lua.load(r#"return math.round(2.0^53)"#).eval().unwrap();
assert_eq!(result, 9007199254740992);
// -2^63 is exactly representable as a float and is a valid Lua integer...
let result: bool = lua.load(r#"return math.round(-2.0^63) == math.mininteger"#).eval().unwrap();
assert!(result);
// ...but 2^63 is one above math.maxinteger and must error
assert!(lua.load(r#"return math.round(2.0^63)"#).exec().is_err());
}
//noinspection RsApproxConstant
#[test]
fn test_round_to_places() {
let lua = super::lua();
// roundn was merged into round - it must no longer exist
let result: bool = lua.load(r#"return math.roundn == nil"#).eval().unwrap();
assert!(result);
// places = 0 behaves like the one-argument form and returns an integer
let result: String = lua.load(r#"return math.type(math.round(3.1415, 0))"#).eval().unwrap();
assert_eq!(result, "integer");
let result: i64 = lua.load(r#"return math.round(3.7, 0)"#).eval().unwrap();
assert_eq!(result, 4);
// places > 0 rounds to N decimal places and returns a float
let result: f64 = lua.load(r#"return math.round(3.1415, 1)"#).eval().unwrap();
assert_eq_f64!(result, 3.1);
let result: f64 = lua.load(r#"return math.round(3.1415, 3)"#).eval().unwrap();
assert_eq_f64!(result, 3.142);
let result: f64 = lua.load(r#"return math.round(2.567, 2)"#).eval().unwrap();
assert_eq_f64!(result, 2.57);
let result: String = lua.load(r#"return math.type(math.round(3.1415, 2))"#).eval().unwrap();
assert_eq!(result, "float");
// Integer input with places > 0 returns the same value as a float
let result: String = lua.load(r#"return math.type(math.round(5, 2))"#).eval().unwrap();
assert_eq!(result, "float");
let result: f64 = lua.load(r#"return math.round(5, 2)"#).eval().unwrap();
assert_eq_f64!(result, 5.0);
// Negative numbers round half away from zero
let result: f64 = lua.load(r#"return math.round(-2.345, 2)"#).eval().unwrap();
assert_eq_f64!(result, -2.35);
// Huge values must not overflow to infinity via the 10^places multiplier
let result: f64 = lua.load(r#"return math.round(1.5e308, 2)"#).eval().unwrap();
assert_eq_f64!(result, 1.5e308);
// Huge places are a no-op (cannot change any f64), not an overflow
let result: f64 = lua.load(r#"return math.round(12.5, 400)"#).eval().unwrap();
assert_eq_f64!(result, 12.5);
// Tiny values still round correctly with large places
let result: f64 = lua.load(r#"return math.round(1.23e-300, 300)"#).eval().unwrap();
assert_eq_f64!(result, 1.0e-300);
// Places given as an integral float is accepted
let result: f64 = lua.load(r#"return math.round(3.14159, 2.0)"#).eval().unwrap();
assert_eq_f64!(result, 3.14);
// Non-integral places must error
assert!(lua.load(r#"return math.round(3.14, 2.5)"#).exec().is_err());
// Negative places must error with a clear message
let err = lua.load(r#"return math.round(3.14, -2)"#).exec().unwrap_err();
assert!(err.to_string().contains("negative"));
// NaN must error
let err = lua.load(r#"return math.round(0/0, 2)"#).exec().unwrap_err();
assert!(err.to_string().contains("finite"));
// Infinity must error
assert!(lua.load(r#"return math.round(math.huge, 2)"#).exec().is_err());
// Non-number must error
let err = lua.load(r#"return math.round("foo", 2)"#).exec().unwrap_err();
assert!(err.to_string().contains("non-number"));
}
#[test]
fn test_is_finite() {
let lua = super::lua();
// Regular numbers are finite
let result: bool = lua.load(r#"return math.isFinite(42)"#).eval().unwrap();
assert!(result);
let result: bool = lua.load(r#"return math.isFinite(-3.14)"#).eval().unwrap();
assert!(result);
let result: bool = lua.load(r#"return math.isFinite(0)"#).eval().unwrap();
assert!(result);
// Infinity is not finite
let result: bool = lua.load(r#"return math.isFinite(math.huge)"#).eval().unwrap();
assert!(!result);
let result: bool = lua.load(r#"return math.isFinite(-math.huge)"#).eval().unwrap();
assert!(!result);
// NaN is not finite (0/0 produces NaN)
let result: bool = lua.load(r#"return math.isFinite(0/0)"#).eval().unwrap();
assert!(!result);
// Non-numbers return false
let result: bool = lua.load(r#"return math.isFinite("hello")"#).eval().unwrap();
assert!(!result);
let result: bool = lua.load(r#"return math.isFinite(nil)"#).eval().unwrap();
assert!(!result);
let result: bool = lua.load(r#"return math.isFinite({})"#).eval().unwrap();
assert!(!result);
// Very large numbers are still finite
let result: bool = lua.load(r#"return math.isFinite(1e308)"#).eval().unwrap();
assert!(result);
// But overflow to infinity is not
let result: bool = lua.load(r#"return math.isFinite(1e309)"#).eval().unwrap();
assert!(!result);
}
#[test]
fn test_sign() {
let lua = super::lua();
// Positive numbers return 1
let result: i64 = lua.load(r#"return math.sign(42)"#).eval().unwrap();
assert_eq!(result, 1);
let result: i64 = lua.load(r#"return math.sign(0.001)"#).eval().unwrap();
assert_eq!(result, 1);
// Negative numbers return -1
let result: i64 = lua.load(r#"return math.sign(-42)"#).eval().unwrap();
assert_eq!(result, -1);
let result: i64 = lua.load(r#"return math.sign(-0.001)"#).eval().unwrap();
assert_eq!(result, -1);
// Zero returns 0
let result: i64 = lua.load(r#"return math.sign(0)"#).eval().unwrap();
assert_eq!(result, 0);
// Negative zero is still zero
let result: i64 = lua.load(r#"return math.sign(-0)"#).eval().unwrap();
assert_eq!(result, 0);
// Positive infinity returns 1
let result: i64 = lua.load(r#"return math.sign(math.huge)"#).eval().unwrap();
assert_eq!(result, 1);
// Negative infinity returns -1
let result: i64 = lua.load(r#"return math.sign(-math.huge)"#).eval().unwrap();
assert_eq!(result, -1);
// NaN returns 0 (NaN is not > 0 and not < 0)
let result: i64 = lua.load(r#"return math.sign(0/0)"#).eval().unwrap();
assert_eq!(result, 0);
// Error on non-number
let err = lua.load(r#"return math.sign("foo")"#).exec().unwrap_err();
assert!(err.to_string().contains("Non-number"));
}
#[test]
fn test_scale() {
let lua = super::lua();
// Scale 0-100 to 0-1
let result: f64 = lua.load(r#"return math.scale(50, 0, 100, 0, 1)"#).eval().unwrap();
assert_eq_f64!(result, 0.5);
// Scale 0-100 to 0-10
let result: f64 = lua.load(r#"return math.scale(25, 0, 100, 0, 10)"#).eval().unwrap();
assert_eq_f64!(result, 2.5);
// Scale with offset ranges
let result: f64 = lua.load(r#"return math.scale(15, 10, 20, 100, 200)"#).eval().unwrap();
assert_eq_f64!(result, 150.0);
// Scale to inverted range
let result: f64 = lua.load(r#"return math.scale(0, 0, 100, 100, 0)"#).eval().unwrap();
assert_eq_f64!(result, 100.0);
let result: f64 = lua.load(r#"return math.scale(100, 0, 100, 100, 0)"#).eval().unwrap();
assert_eq_f64!(result, 0.0);
// Value outside input range (no clamp)
let result: f64 = lua.load(r#"return math.scale(150, 0, 100, 0, 10)"#).eval().unwrap();
assert_eq_f64!(result, 15.0);
let result: f64 = lua.load(r#"return math.scale(-50, 0, 100, 0, 10)"#).eval().unwrap();
assert_eq_f64!(result, -5.0);
// Value outside input range with clamp=true
let result: f64 = lua.load(r#"return math.scale(150, 0, 100, 0, 10, true)"#).eval().unwrap();
assert_eq_f64!(result, 10.0);
let result: f64 = lua.load(r#"return math.scale(-50, 0, 100, 0, 10, true)"#).eval().unwrap();
assert_eq_f64!(result, 0.0);
// Clamp with inverted output range
let result: f64 = lua.load(r#"return math.scale(150, 0, 100, 10, 0, true)"#).eval().unwrap();
assert_eq_f64!(result, 0.0);
let result: f64 = lua.load(r#"return math.scale(-50, 0, 100, 10, 0, true)"#).eval().unwrap();
assert_eq_f64!(result, 10.0);
// Clamp=false (explicit) behaves same as default
let result: f64 = lua.load(r#"return math.scale(150, 0, 100, 0, 10, false)"#).eval().unwrap();
assert_eq_f64!(result, 15.0);
// Zero-width output range is allowed (always returns outMin)
let result: f64 = lua.load(r#"return math.scale(50, 0, 100, 5, 5)"#).eval().unwrap();
assert_eq_f64!(result, 5.0);
// Boundary values
let result: f64 = lua.load(r#"return math.scale(0, 0, 100, 0, 10)"#).eval().unwrap();
assert_eq_f64!(result, 0.0);
let result: f64 = lua.load(r#"return math.scale(100, 0, 100, 0, 10)"#).eval().unwrap();
assert_eq_f64!(result, 10.0);
// Negative input range
let result: f64 = lua.load(r#"return math.scale(-50, -100, 0, 0, 10)"#).eval().unwrap();
assert_eq_f64!(result, 5.0);
// Inverted input range (inMin > inMax)
let result: f64 = lua.load(r#"return math.scale(75, 100, 0, 0, 10)"#).eval().unwrap();
assert_eq_f64!(result, 2.5);
// Error on zero-width input range (division by zero)
let err = lua.load(r#"return math.scale(50, 100, 100, 0, 10)"#).exec().unwrap_err();
assert!(err.to_string().contains("Input range cannot be zero"));
// Error on non-number value
let err = lua.load(r#"return math.scale("foo", 0, 100, 0, 10)"#).exec().unwrap_err();
assert!(err.to_string().contains("Non-number"));
// Error on non-number range
let err = lua.load(r#"return math.scale(50, "a", 100, 0, 10)"#).exec().unwrap_err();
assert!(err.to_string().contains("Non-number"));
// Infinity input produces infinity output (no clamp)
let result: bool = lua
.load(r#"return math.scale(math.huge, 0, 100, 0, 10) == math.huge"#)
.eval()
.unwrap();
assert!(result);
// Infinity input with clamp gets clamped
let result: f64 = lua.load(r#"return math.scale(math.huge, 0, 100, 0, 10, true)"#).eval().unwrap();
assert_eq_f64!(result, 10.0);
let result: f64 = lua
.load(r#"return math.scale(-math.huge, 0, 100, 0, 10, true)"#)
.eval()
.unwrap();
assert_eq_f64!(result, 0.0);
// NaN input produces NaN output
let result: bool = lua
.load(r#"local r = math.scale(0/0, 0, 100, 0, 10); return r ~= r"#)
.eval()
.unwrap();
assert!(result); // NaN ~= NaN is true
}

@ -0,0 +1,41 @@
//! Tests for the Lua stdlib, ported from flowbox-rt's fb_lua `lua_tests` and
//! adapted to this project's sandbox and stdlib.
mod async_tests;
mod core_tests;
mod math_tests;
mod os_tests;
mod table_tests;
mod utils_tests;
use mlua::Lua;
/// A fresh sandboxed Lua with the full stdlib installed - the same state scripts get.
pub(crate) fn lua() -> Lua {
crate::sandbox::create_sandboxed_lua().unwrap()
}
/// Compare two JSON strings as parsed values (key order independent).
#[allow(dead_code)]
pub(crate) fn json_eq(a: &str, b: &str) -> bool {
let a: serde_json::Value = serde_json::from_str(a).unwrap();
let b: serde_json::Value = serde_json::from_str(b).unwrap();
a == b
}
/// Assert that two f64 values are equal within an epsilon (default `f64::EPSILON`).
macro_rules! assert_eq_f64 {
($a:expr, $b:expr) => {
assert_eq_f64!($a, $b, f64::EPSILON)
};
($a:expr, $b:expr, $eps:expr) => {{
let (a, b): (f64, f64) = ($a, $b);
let eps: f64 = $eps;
assert!(
(a - b).abs() <= eps,
"assertion failed: `(left !== right)` (left: `{a:?}`, right: `{b:?}`, epsilon: `{eps:?}`, diff: `{:?}`)",
(a - b).abs()
);
}};
}
pub(crate) use assert_eq_f64;

@ -0,0 +1,173 @@
//! Tests for the sandboxed `os` table.
//!
//! Ported from flowbox-rt's fb_lua os_tests. Unlike flowbox, this project
//! keeps the real Lua 5.5 os.date/os.time/os.difftime (flowbox used a
//! chrono-based shim), so a few edge cases differ - see individual tests.
//! os.sleep is async here and is covered by async_tests, not this file.
use std::time::{Duration, Instant};
#[test]
fn test_os_time_now() {
let lua = super::lua();
let result: i64 = lua.load(r#"return os.time()"#).eval().unwrap();
let now = chrono::Utc::now().timestamp();
assert!((result - now).abs() <= 5, "os.time() = {result}, expected ~{now}");
}
#[test]
fn test_os_time_from_table() {
let lua = super::lua();
// Round trip through local time - independent of the host timezone
let ok: bool = lua
.load(
r#"
local t = os.time({year = 2020, month = 1, day = 15, hour = 10, min = 30, sec = 20})
local d = os.date("*t", t)
return d.year == 2020 and d.month == 1 and d.day == 15
and d.hour == 10 and d.min == 30 and d.sec == 20
"#,
)
.eval()
.unwrap();
assert!(ok);
// Out-of-range fields are normalized like mktime; hour defaults to 12
let ok: bool = lua
.load(
r#"
local a = os.time({year = 2020, month = 14, day = 1})
local b = os.time({year = 2021, month = 2, day = 1})
return a == b
"#,
)
.eval()
.unwrap();
assert!(ok);
// The normalized fields are written back into the table
let ok: bool = lua
.load(
r#"
local tbl = {year = 2020, month = 14, day = 1}
os.time(tbl)
return tbl.year == 2021 and tbl.month == 2 and tbl.day == 1
and tbl.hour == 12 and tbl.wday == 2 -- 2021-02-01 was a Monday
"#,
)
.eval()
.unwrap();
assert!(ok);
// Missing mandatory field is an error
let ok: bool = lua.load(r#"return (pcall(os.time, {year = 2020}))"#).eval().unwrap();
assert!(!ok);
}
#[test]
fn test_os_date_format() {
let lua = super::lua();
let result: String = lua.load(r#"return os.date("!%Y-%m-%dT%H:%M:%S", 1000000000)"#).eval().unwrap();
assert_eq!(result, "2001-09-09T01:46:40");
// Epoch
let result: String = lua.load(r#"return os.date("!%Y-%m-%d %H:%M:%S", 0)"#).eval().unwrap();
assert_eq!(result, "1970-01-01 00:00:00");
// Stock Lua difference: flowbox's chrono shim truncated float time values;
// real Lua 5.5 requires an integer-representable time and raises an error.
let ok: bool = lua.load(r#"return (pcall(os.date, "!%Y-%m-%d", 0.9))"#).eval().unwrap();
assert!(!ok, "os.date with a fractional time value should raise in stock Lua");
// Default format is %c, applied to the current time
let result: String = lua.load(r#"return os.date()"#).eval().unwrap();
assert!(!result.is_empty());
// Invalid format specifier is an error, not garbage output
let ok: bool = lua.load(r#"return (pcall(os.date, "%Q"))"#).eval().unwrap();
assert!(!ok);
}
#[test]
fn test_os_date_table() {
let lua = super::lua();
// 2001-09-09T01:46:40Z was a Sunday, day 252 of the year
let ok: bool = lua
.load(
r#"
local d = os.date("!*t", 1000000000)
return d.year == 2001 and d.month == 9 and d.day == 9
and d.hour == 1 and d.min == 46 and d.sec == 40
and d.wday == 1 and d.yday == 252
"#,
)
.eval()
.unwrap();
assert!(ok);
}
/// os.clock is replaced with monotonic wall time since state creation.
#[test]
fn test_os_clock() {
let start = Instant::now();
let lua = super::lua();
std::thread::sleep(Duration::from_millis(30));
let clock: f64 = lua.load(r#"return os.clock()"#).eval().unwrap();
assert!(
(start.elapsed().as_secs_f64() - clock).abs() < 0.1,
"Clock function works"
);
// 200 ms elapses, so it should be reported accurately
std::thread::sleep(Duration::from_millis(200));
let clock2: f64 = lua.load(r#"return os.clock()"#).eval().unwrap();
assert!((clock2 - clock - 0.2) < 0.03, "Clock tracks time");
}
#[test]
fn test_os_difftime() {
let lua = super::lua();
let result: f64 = lua.load(r#"return os.difftime(10, 4)"#).eval().unwrap();
assert_eq!(result, 6.0);
let ok: bool = lua.load(r#"return (pcall(os.difftime, 10))"#).eval().unwrap();
assert!(!ok);
}
#[test]
fn test_os_missing_dangerous_functions() {
let lua = super::lua();
let os_table = lua.globals().get::<mlua::Table>("os").unwrap();
for name in ["execute", "exit", "getenv", "remove", "rename", "setlocale", "tmpname"] {
assert!(
!os_table.contains_key(name).unwrap(),
"os.{name} should be removed from the sandbox"
);
}
}
#[test]
fn test_os_microtime() {
let lua = super::lua();
let result1: f64 = lua.load(r#"return os.microtime()"#).eval().unwrap();
std::thread::sleep(Duration::from_millis(100));
let result2: f64 = lua.load(r#"return os.microtime()"#).eval().unwrap();
assert!(
(result2 - result1 - 0.1).abs() < 0.03,
"os.microtime() should return fractional seconds"
);
// It is a Unix timestamp
let now = chrono::Utc::now().timestamp() as f64;
assert!((result2 - now).abs() <= 5.0, "os.microtime() = {result2}, expected ~{now}");
}

File diff suppressed because it is too large Load Diff

@ -0,0 +1,813 @@
//! Tests for the `utils` global: NULL sentinel, isNull, toJSON/fromJSON, dump
//! (Rust side) and try, tryn (Lua side, lua/stdlib/utils.lua).
//!
//! Ported from flowbox-rt fb_lua utils_tests. `utils.dump` is the flowbox Rust
//! implementation (src/stdlib/lua_dump.rs), so the dump tests match flowbox's.
use super::json_eq;
#[test]
fn test_dump() {
let lua = super::lua();
let dump = |code: &str| -> String {
lua.load(format!("return utils.dump({})", code))
.eval::<String>()
.unwrap()
};
// nil
assert_eq!(dump("nil"), "nil");
// booleans
assert_eq!(dump("true"), "true");
assert_eq!(dump("false"), "false");
// integers
assert_eq!(dump("1"), "1");
assert_eq!(dump("0"), "0");
assert_eq!(dump("-42"), "-42");
// floats - whole numbers show .0 (Lua 5.4 tostring)
assert_eq!(dump("1.0"), "1.0");
assert_eq!(dump("0.0"), "0.0");
assert_eq!(dump("-5.0"), "-5.0");
// floats - with fractional part
assert_eq!(dump("1.5"), "1.5");
assert_eq!(dump("-3.14159"), "-3.14159");
assert_eq!(dump("0.001"), "0.001");
// strings are quoted and escaped JSON-style
assert_eq!(dump("'hello'"), "\"hello\"");
assert_eq!(dump("''"), "\"\""); // empty
assert_eq!(dump("'with spaces'"), "\"with spaces\"");
assert_eq!(dump("'special\\nchars\\ttab'"), "\"special\\nchars\\ttab\"");
// empty table
assert_eq!(dump("{}"), "{}");
// simple table with string key
assert_eq!(dump("{foo=1}"), "{foo=1}");
assert_eq!(dump("{_foo=1}"), "{_foo=1}");
assert_eq!(dump("{_1=1}"), "{_1=1}");
// a string key that does not look like an identifier is bracketed and quoted
assert_eq!(dump("{[\"1\"]=1}"), "{[\"1\"]=1}");
// nested table
assert_eq!(dump("{foo={bar=1}}"), "{foo={bar=1}}");
// array-style table (numeric keys)
assert_eq!(dump("{10, 20, 30}"), "{10, 20, 30}");
// mixed keys - order may vary, so check by substrings
let dumped = dump("{foo=1, [999]='a'}");
assert!(dumped.contains("foo=1"), "got: {dumped}");
assert!(dumped.contains("[999]=\"a\""), "got: {dumped}");
// a table with both an array part and hash keys dumps both
assert_eq!(dump("{10, 20, foo='hash'}"), "{10, 20, foo=\"hash\"}");
// function
assert_eq!(dump("function() end"), "<Function>");
}
#[test]
fn test_dump_deep_nesting() {
let lua = super::lua();
// Recursion limit: tables at depth > 20 are rendered as <TRUNCATED>
// Build a table nested 25 levels deep - truncation happens at depth 21
let deep_result: String = lua
.load(
r#"
local t = {val="leaf"}
for i = 1, 25 do
t = {nested=t}
end
return utils.dump(t)
"#,
)
.eval()
.unwrap();
// 20 levels of {nested=, then the innermost table's value truncated at depth 21
let expected = format!("{}{{nested=<TRUNCATED>}}{}", "{nested=".repeat(20), "}".repeat(20));
assert_eq!(deep_result, expected);
// A table just within the limit still dumps fully
let shallow_result: String = lua
.load(
r#"
local t = {val="leaf"}
for i = 1, 5 do
t = {nested=t}
end
return utils.dump(t)
"#,
)
.eval()
.unwrap();
let expected = format!("{}{{val=\"leaf\"}}{}", "{nested=".repeat(5), "}".repeat(5));
assert_eq!(shallow_result, expected);
}
#[test]
fn test_dump_circular() {
let lua = super::lua();
// There is no cycle detection; the depth limit terminates the recursion,
// so a self-referencing table dumps 21 levels and then truncates
let result: String = lua
.load(
r#"
local t = {}
t.self = t
return utils.dump(t)
"#,
)
.eval()
.unwrap();
let expected = format!("{}<TRUNCATED>{}", "{self=".repeat(21), "}".repeat(21));
assert_eq!(result, expected);
// The same table appearing twice as a sibling dumps twice
let result: String = lua
.load(
r#"
local shared = {x = 1}
return utils.dump({shared, shared})
"#,
)
.eval()
.unwrap();
assert_eq!(result, "{{x=1}, {x=1}}");
}
#[test]
fn test_to_json() {
let lua = super::lua();
// Primitives
let result: String = lua.load(r#"return utils.toJSON(nil)"#).eval().unwrap();
assert_eq!(result, "null");
let result: String = lua.load(r#"return utils.toJSON(true)"#).eval().unwrap();
assert_eq!(result, "true");
let result: String = lua.load(r#"return utils.toJSON(false)"#).eval().unwrap();
assert_eq!(result, "false");
let result: String = lua.load(r#"return utils.toJSON(42)"#).eval().unwrap();
assert_eq!(result, "42");
let result: String = lua.load(r#"return utils.toJSON(3.14)"#).eval().unwrap();
assert_eq!(result, "3.14");
let result: String = lua.load(r#"return utils.toJSON("hello")"#).eval().unwrap();
assert_eq!(result, "\"hello\"");
// Empty table serializes as an empty object
let result: String = lua.load(r#"return utils.toJSON({})"#).eval().unwrap();
assert_eq!(result, "{}");
// Array-style table
let result: String = lua.load(r#"return utils.toJSON({1, 2, 3})"#).eval().unwrap();
assert_eq!(result, "[1,2,3]");
// Object-style table
let result: String = lua.load(r#"return utils.toJSON({a=1})"#).eval().unwrap();
assert_eq!(result, "{\"a\":1}");
// Nested structure
let result: String = lua
.load(r#"return utils.toJSON({nested={value=42}})"#)
.eval()
.unwrap();
assert_eq!(result, "{\"nested\":{\"value\":42}}");
}
#[test]
fn test_to_json_mixed_and_integer_keys() {
let lua = super::lua();
// A table with both a sequence part and hash keys is serialized as an
// object; integer keys become string keys
let result: String = lua
.load(r#"return utils.toJSON({1, 2, x = 3})"#)
.eval()
.unwrap();
assert!(json_eq(&result, r#"{"1":1,"2":2,"x":3}"#), "got: {result}");
// Non-sequential integer keys become string object keys
let result: String = lua
.load(r#"return utils.toJSON({a = 1, [10] = "x"})"#)
.eval()
.unwrap();
assert!(json_eq(&result, r#"{"a":1,"10":"x"}"#), "got: {result}");
}
#[test]
fn test_to_json_pretty() {
let lua = super::lua();
// Second argument enables pretty-printing
let result: String = lua.load(r#"return utils.toJSON({a=1}, true)"#).eval().unwrap();
assert_eq!(result, "{\n \"a\": 1\n}");
// Explicit false behaves like the default compact output
let result: String = lua.load(r#"return utils.toJSON({a=1}, false)"#).eval().unwrap();
assert_eq!(result, "{\"a\":1}");
}
#[test]
fn test_to_json_unserializable_errors() {
let lua = super::lua();
// Unserializable values must raise an error instead of silently returning "null"
let err = lua.load(r#"return utils.toJSON(function() end)"#).exec().unwrap_err();
assert!(err.to_string().contains("toJSON"), "got: {err}");
assert!(err.to_string().contains("function"), "got: {err}");
// A single bad value must not silently discard the whole structure
let err = lua
.load(r#"return utils.toJSON({x = 1, f = function() end})"#)
.exec()
.unwrap_err();
assert!(err.to_string().contains("toJSON"), "got: {err}");
// Self-referencing tables cannot be serialized (caught by the depth limit)
let err = lua
.load(
r#"
local t = {}
t.self = t
return utils.toJSON(t)
"#,
)
.exec()
.unwrap_err();
assert!(err.to_string().contains("toJSON"), "got: {err}");
assert!(err.to_string().contains("nesting too deep"), "got: {err}");
// NaN and Infinity have no JSON representation
let err = lua.load(r#"return utils.toJSON(0/0)"#).exec().unwrap_err();
assert!(err.to_string().contains("NaN"), "got: {err}");
let err = lua.load(r#"return utils.toJSON(math.huge)"#).exec().unwrap_err();
assert!(err.to_string().contains("Infinity"), "got: {err}");
// Only string and integer keys can become JSON object keys
let err = lua.load(r#"return utils.toJSON({[true] = 1})"#).exec().unwrap_err();
assert!(err.to_string().contains("object key"), "got: {err}");
let err = lua.load(r#"return utils.toJSON({[1.5] = 1})"#).exec().unwrap_err();
assert!(err.to_string().contains("object key"), "got: {err}");
// An integer key that stringifies onto an existing string key must error
// rather than silently dropping one of the two values.
let err = lua
.load(r#"return utils.toJSON({[1] = "int", ["1"] = "str"})"#)
.exec()
.unwrap_err();
assert!(err.to_string().contains("duplicate object key"), "got: {err}");
// A non-UTF-8 string value gets a clear prefixed error, not mlua's bare
// "error converting Lua string to &str".
let err = lua.load(r#"return utils.toJSON("\255bad")"#).exec().unwrap_err();
assert!(err.to_string().contains("utils.toJSON"), "got: {err}");
assert!(err.to_string().contains("UTF-8"), "got: {err}");
}
#[test]
fn test_null_sentinel() {
let lua = super::lua();
// utils.NULL exists and is distinct from nil
let result: bool = lua.load(r#"return utils.NULL ~= nil"#).eval().unwrap();
assert!(result);
// isNull identifies the sentinel and nothing else
let result: bool = lua.load(r#"return utils.isNull(utils.NULL)"#).eval().unwrap();
assert!(result);
let result: bool = lua
.load(
r#"
return utils.isNull(nil) or utils.isNull(0) or utils.isNull("")
or utils.isNull({}) or utils.isNull(false)
"#,
)
.eval()
.unwrap();
assert!(!result);
// JSON null deserializes to the sentinel - consistently at any nesting level
let result: bool = lua.load(r#"return utils.isNull(utils.fromJSON("null"))"#).eval().unwrap();
assert!(result);
let result: bool = lua
.load(
r#"
local obj = utils.fromJSON('{"a": null, "b": 1}')
return utils.isNull(obj.a) and obj.a ~= nil and obj.b == 1
"#,
)
.eval()
.unwrap();
assert!(result);
// Nulls in arrays preserve the array length
let result: bool = lua
.load(
r#"
local arr = utils.fromJSON('[1, null, 3]')
return #arr == 3 and utils.isNull(arr[2]) and arr[1] == 1 and arr[3] == 3
"#,
)
.eval()
.unwrap();
assert!(result);
// The sentinel serializes back to JSON null
let result: String = lua.load(r#"return utils.toJSON(utils.NULL)"#).eval().unwrap();
assert_eq!(result, "null");
let result: String = lua.load(r#"return utils.toJSON({a = utils.NULL})"#).eval().unwrap();
assert_eq!(result, "{\"a\":null}");
// dump renders the sentinel as NULL, bare and inside tables
// (other light userdata dumps as <LightUserData>)
let result: String = lua.load(r#"return utils.dump(utils.NULL)"#).eval().unwrap();
assert_eq!(result, "NULL");
let result: String = lua.load(r#"return utils.dump({a = utils.NULL})"#).eval().unwrap();
assert_eq!(result, "{a=NULL}");
let result: String = lua.load(r#"return utils.dump({1, utils.NULL, 3})"#).eval().unwrap();
assert_eq!(result, "{1, NULL, 3}");
}
/// utils.NULL is mlua's own null sentinel (Value::NULL, a null-pointer light
/// userdata), so nulls interoperate with mlua's serde layer in both directions.
#[test]
fn test_null_sentinel_matches_mlua() {
use mlua::LuaSerdeExt;
let lua = super::lua();
let null_val: mlua::Value = lua.load(r#"return utils.NULL"#).eval().unwrap();
assert_eq!(null_val, mlua::Value::NULL);
// a null produced by mlua (lua.null()) is recognized by utils.isNull
let is_null: bool = lua
.globals()
.get::<mlua::Table>("utils")
.unwrap()
.get::<mlua::Function>("isNull")
.unwrap()
.call(lua.null())
.unwrap();
assert!(is_null);
}
#[test]
fn test_utils_table_extensible() {
let lua = super::lua();
// utils is a plain global table just like math and table - scripts can extend it
let result: i64 = lua
.load(
r#"
utils.myHelper = function() return 41 + 1 end
return utils.myHelper()
"#,
)
.eval()
.unwrap();
assert_eq!(result, 42);
// and the built-in functions are of course still there
let result: bool = lua.load(r#"return type(utils.try) == "function""#).eval().unwrap();
assert!(result);
}
#[test]
fn test_try_tryn_arg_validation() {
let lua = super::lua();
// The callback must be a function, rejected with a clear message
let err = lua.load(r#"return utils.try(5)"#).exec().unwrap_err();
assert!(err.to_string().contains("not a function"), "got: {err}");
let err = lua.load(r#"return utils.try(nil)"#).exec().unwrap_err();
assert!(err.to_string().contains("not a function"), "got: {err}");
let err = lua.load(r#"return utils.tryn(2, 5)"#).exec().unwrap_err();
assert!(err.to_string().contains("not a function"), "got: {err}");
// expectedCount must be a non-negative integer
let err = lua.load(r#"return utils.tryn(2.5, function() end)"#).exec().unwrap_err();
assert!(err.to_string().contains("integer"), "got: {err}");
assert!(lua.load(r#"return utils.tryn(-1, function() end)"#).exec().is_err());
}
#[test]
fn test_try_error_values() {
let lua = super::lua();
// A thrown table is passed through as-is (pcall semantics), so scripts can
// attach structured data to errors
let result: bool = lua
.load(
r#"
local v, err = utils.try(function() error({code = 42}) end)
return v == nil and type(err) == "table" and err.code == 42
"#,
)
.eval()
.unwrap();
assert!(result);
// A nil error value still yields a non-nil err, so `if err` always detects failure
let result: bool = lua
.load(
r#"
local v, err = utils.try(function() error() end)
return err ~= nil
"#,
)
.eval()
.unwrap();
assert!(result);
let result: bool = lua
.load(
r#"
local a, err = utils.tryn(1, function() error() end)
return err ~= nil
"#,
)
.eval()
.unwrap();
assert!(result);
}
#[test]
fn test_tryn_return_counts() {
let lua = super::lua();
// tryn returns exactly expectedCount + 1 values (the last one is the error slot),
// both on success and on failure
let result: i64 = lua
.load(r#"return select('#', utils.tryn(2, function() return 1 end))"#)
.eval()
.unwrap();
assert_eq!(result, 3);
let result: i64 = lua
.load(r#"return select('#', utils.tryn(2, function() error("x") end))"#)
.eval()
.unwrap();
assert_eq!(result, 3);
// expectedCount = 0 gives just the error slot
let result: bool = lua
.load(
r#"
local err = utils.tryn(0, function() return 1, 2 end)
return err == nil and select('#', utils.tryn(0, function() end)) == 1
"#,
)
.eval()
.unwrap();
assert!(result);
}
#[test]
fn test_tryn_count_cap() {
let lua = super::lua();
// Excessive expected_count must error early instead of allocating huge buffers
let err = lua
.load(r#"return utils.tryn(1000000, function() return 1 end)"#)
.exec()
.unwrap_err();
assert!(err.to_string().contains("at most"), "got: {err}");
let err = lua
.load(r#"return utils.tryn(65, function() return 1 end)"#)
.exec()
.unwrap_err();
assert!(err.to_string().contains("at most"), "got: {err}");
// The maximum allowed count still works
let result: i64 = lua
.load(
r#"
local r = {utils.tryn(64, function() return 42 end)}
return r[1]
"#,
)
.eval()
.unwrap();
assert_eq!(result, 42);
}
#[test]
fn test_from_json() {
let lua = super::lua();
// Primitives - JSON null becomes the utils.NULL sentinel, not nil
lua.load(r#"assert(utils.isNull(utils.fromJSON("null")))"#).exec().unwrap();
let result: bool = lua.load(r#"return utils.fromJSON("true")"#).eval().unwrap();
assert!(result);
let result: bool = lua.load(r#"return utils.fromJSON("false")"#).eval().unwrap();
assert!(!result);
let result: i64 = lua.load(r#"return utils.fromJSON("42")"#).eval().unwrap();
assert_eq!(result, 42);
// Whole JSON numbers arrive as Lua integers, fractional ones as floats
let result: bool = lua
.load(r#"return math.type(utils.fromJSON("42")) == "integer""#)
.eval()
.unwrap();
assert!(result);
let result: f64 = lua.load(r#"return utils.fromJSON("3.14")"#).eval().unwrap();
assert!((result - 3.14).abs() < 0.001);
let result: bool = lua
.load(r#"return math.type(utils.fromJSON("3.14")) == "float""#)
.eval()
.unwrap();
assert!(result);
let result: String = lua.load(r#"return utils.fromJSON('"hello"')"#).eval().unwrap();
assert_eq!(result, "hello");
// Array
let result: i64 = lua
.load(
r#"
local arr = utils.fromJSON("[1, 2, 3]")
return arr[1] + arr[2] + arr[3]
"#,
)
.eval()
.unwrap();
assert_eq!(result, 6);
// Object
let result: i64 = lua
.load(
r#"
local obj = utils.fromJSON('{"a": 10, "b": 20}')
return obj.a + obj.b
"#,
)
.eval()
.unwrap();
assert_eq!(result, 30);
// Nested structure
let result: i64 = lua
.load(
r#"
local data = utils.fromJSON('{"nested": {"value": 42}}')
return data.nested.value
"#,
)
.eval()
.unwrap();
assert_eq!(result, 42);
// Invalid JSON should error, and the message names the function
let err = lua.load(r#"return utils.fromJSON("not valid json")"#).exec().unwrap_err();
assert!(err.to_string().contains("fromJSON"), "got: {err}");
}
#[test]
fn test_json_roundtrip() {
let lua = super::lua();
// Round-trip test: Lua -> JSON -> Lua
let result: i64 = lua
.load(
r#"
local original = {x = 10, y = 20, items = {1, 2, 3}}
local json = utils.toJSON(original)
local restored = utils.fromJSON(json)
return restored.x + restored.y + restored.items[1]
"#,
)
.eval()
.unwrap();
assert_eq!(result, 31); // 10 + 20 + 1
// NULL survives a Lua -> JSON -> Lua round trip
let result: bool = lua
.load(
r#"
local restored = utils.fromJSON(utils.toJSON({a = utils.NULL}))
return utils.isNull(restored.a)
"#,
)
.eval()
.unwrap();
assert!(result);
}
#[test]
fn test_try() {
let lua = super::lua();
// Success case: returns value, nil error
let (value, has_error): (i64, bool) = lua
.load(
r#"
local val, err = utils.try(function() return 42 end)
return val, err ~= nil
"#,
)
.eval()
.unwrap();
assert_eq!(value, 42);
assert!(!has_error);
// Error case: returns nil value, error
let (is_nil, has_error): (bool, bool) = lua
.load(
r#"
local val, err = utils.try(function() error("boom") end)
return val == nil, err ~= nil
"#,
)
.eval()
.unwrap();
assert!(is_nil);
assert!(has_error);
// Error message is preserved
let contains_boom: bool = lua
.load(
r#"
local val, err = utils.try(function() error("boom") end)
return tostring(err):find("boom") ~= nil
"#,
)
.eval()
.unwrap();
assert!(contains_boom);
}
#[test]
fn test_try_shorthand() {
let lua = super::lua();
// pcall-style shorthand: the function and its arguments, no closure needed
let (value, has_error): (String, bool) = lua
.load(
r#"
local val, err = utils.try(string.rep, "ab", 3)
return val, err ~= nil
"#,
)
.eval()
.unwrap();
assert_eq!(value, "ababab");
assert!(!has_error);
// Errors thrown by the called function are captured the same way as with a closure
let captured: bool = lua
.load(
r#"
local val, err = utils.try(error, "boom")
return val == nil and tostring(err):find("boom") ~= nil
"#,
)
.eval()
.unwrap();
assert!(captured);
// nil arguments are forwarded with the argument count preserved (pcall semantics)
let result: i64 = lua
.load(
r#"
local function countArgs(...) return select('#', ...) end
local n = utils.try(countArgs, nil, nil, nil)
return n
"#,
)
.eval()
.unwrap();
assert_eq!(result, 3);
}
#[test]
fn test_tryn() {
let lua = super::lua();
// Success case with multiple return values
let (a, b, c, has_error): (i64, i64, i64, bool) = lua
.load(
r#"
local a, b, c, err = utils.tryn(3, function() return 1, 2, 3 end)
return a, b, c, err ~= nil
"#,
)
.eval()
.unwrap();
assert_eq!((a, b, c), (1, 2, 3));
assert!(!has_error);
// Error case: all values are nil, error is present
let (all_nil, has_error): (bool, bool) = lua
.load(
r#"
local a, b, c, err = utils.tryn(3, function() error("fail") end)
return a == nil and b == nil and c == nil, err ~= nil
"#,
)
.eval()
.unwrap();
assert!(all_nil);
assert!(has_error);
// Fewer return values than expected: pads with nil
let (a, b_nil, c_nil, has_error): (i64, bool, bool, bool) = lua
.load(
r#"
local a, b, c, err = utils.tryn(3, function() return 100 end)
return a, b == nil, c == nil, err ~= nil
"#,
)
.eval()
.unwrap();
assert_eq!(a, 100);
assert!(b_nil);
assert!(c_nil);
assert!(!has_error);
// More return values than expected: truncates
let (a, b, has_error): (i64, i64, bool) = lua
.load(
r#"
local a, b, err = utils.tryn(2, function() return 1, 2, 3, 4, 5 end)
return a, b, err ~= nil
"#,
)
.eval()
.unwrap();
assert_eq!((a, b), (1, 2));
assert!(!has_error);
}
#[test]
fn test_tryn_shorthand() {
let lua = super::lua();
// pcall-style shorthand: arguments after the callback are passed to it
let (q, r, has_error): (i64, i64, bool) = lua
.load(
r#"
local function divmod(a, b) return a // b, a % b end
local q, r, err = utils.tryn(2, divmod, 17, 5)
return q, r, err ~= nil
"#,
)
.eval()
.unwrap();
assert_eq!((q, r), (3, 2));
assert!(!has_error);
// Error case: arguments are passed, the error lands in the last slot
let (all_nil, captured): (bool, bool) = lua
.load(
r#"
local function fail(msg) error("failed: " .. msg) end
local a, b, err = utils.tryn(2, fail, "badly")
return a == nil and b == nil, tostring(err):find("failed: badly") ~= nil
"#,
)
.eval()
.unwrap();
assert!(all_nil);
assert!(captured);
// nil arguments are forwarded with the argument count preserved (pcall semantics)
let result: i64 = lua
.load(
r#"
local function countArgs(...) return select('#', ...) end
local n, err = utils.tryn(1, countArgs, nil, 5, nil)
return n
"#,
)
.eval()
.unwrap();
assert_eq!(result, 3);
}

@ -2,6 +2,11 @@ use std::path::PathBuf;
use clap::Parser; use clap::Parser;
// Lua source embedded in test strings (e.g. `math.round(3.1415, 2)`) trips
// clippy's approx_constant lint, which mistakes the literals for `PI`.
#[cfg(test)]
#[allow(clippy::approx_constant)]
mod lua_tests;
mod repl; mod repl;
mod sandbox; mod sandbox;
mod stdlib; mod stdlib;
@ -16,9 +21,18 @@ struct Args {
} }
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> { async fn main() {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init(); env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init();
if let Err(err) = run().await {
// Display (not Debug) so a Lua error's traceback and multi-line messages
// read as intended instead of as one escaped line.
eprintln!("{err}");
std::process::exit(1);
}
}
async fn run() -> Result<(), Box<dyn std::error::Error>> {
let args = Args::parse(); let args = Args::parse();
let lua = sandbox::create_sandboxed_lua()?; let lua = sandbox::create_sandboxed_lua()?;
@ -27,15 +41,23 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
return repl::run(&lua).await; return repl::run(&lua).await;
}; };
let source = tokio::fs::read_to_string(&filepath).await?; // Read as bytes, not a UTF-8 String: Lua source (and its string literals) are
// byte strings, and stock Lua runs files that aren't valid UTF-8.
let source = tokio::fs::read(&filepath)
.await
.map_err(|e| format!("a: cannot read {}: {e}", filepath.display()))?;
// Strip a leading UTF-8 BOM, as stock Lua does, so a BOM before a shebang
// (common from Windows editors) doesn't reach the lexer.
let source = source.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(&source);
// Skip a leading shebang line like stock Lua's luaL_loadfilex does (it // Skip a leading shebang line, if present. The newline is kept so error
// skips any first line starting with '#'). The newline is kept so error
// messages still report correct line numbers. // messages still report correct line numbers.
let chunk = if source.starts_with('#') { let chunk: &[u8] = if source.first() == Some(&b'#') {
&source[source.find('\n').unwrap_or(source.len())..] let nl = source.iter().position(|&b| b == b'\n').unwrap_or(source.len());
&source[nl..]
} else { } else {
source.as_str() source
}; };
lua.load(chunk) lua.load(chunk)

@ -1,16 +1,14 @@
//! Interactive read-eval-print loop. //! Interactive read-eval-print loop.
//! //!
//! Started when `a` is run with no script path. Each line is compiled and run in //! Each line is compiled and run in exactly the same way scripts are run,
//! the same sandboxed Lua state used for scripts, so the full stdlib (including //! keeping the Lua state between lines.
//! the async http/sqlite/os.sleep calls) is available. Evaluation uses the async
//! executor, matching how files are run.
use mlua::prelude::{LuaFunction, LuaMultiValue, LuaTable}; use mlua::prelude::{LuaFunction, LuaMultiValue, LuaTable};
use mlua::Lua; use mlua::Lua;
use rustyline::error::ReadlineError; use rustyline::error::ReadlineError;
use rustyline::DefaultEditor; use rustyline::DefaultEditor;
const PROMPT: &str = "a> "; const PROMPT: &str = ">>> ";
const CONTINUATION_PROMPT: &str = "..> "; const CONTINUATION_PROMPT: &str = "..> ";
/// Outcome of trying to compile a piece of REPL input. /// Outcome of trying to compile a piece of REPL input.
@ -86,6 +84,9 @@ pub(crate) async fn run(lua: &Lua) -> Result<(), Box<dyn std::error::Error>> {
// Accumulated lines of an as-yet-incomplete statement. // Accumulated lines of an as-yet-incomplete statement.
let mut buffer = String::new(); let mut buffer = String::new();
// Guards against an unbroken stream of read errors spinning forever (e.g. a
// reader that keeps failing without ever reaching EOF).
let mut consecutive_errors = 0u32;
loop { loop {
let prompt = if buffer.is_empty() { let prompt = if buffer.is_empty() {
@ -96,6 +97,7 @@ pub(crate) async fn run(lua: &Lua) -> Result<(), Box<dyn std::error::Error>> {
match editor.readline(prompt) { match editor.readline(prompt) {
Ok(line) => { Ok(line) => {
consecutive_errors = 0;
if buffer.is_empty() { if buffer.is_empty() {
buffer = line; buffer = line;
} else { } else {
@ -127,15 +129,24 @@ pub(crate) async fn run(lua: &Lua) -> Result<(), Box<dyn std::error::Error>> {
// Ctrl-C: abandon the current input and start fresh. // Ctrl-C: abandon the current input and start fresh.
Err(ReadlineError::Interrupted) => { Err(ReadlineError::Interrupted) => {
buffer.clear(); buffer.clear();
consecutive_errors = 0;
} }
// Ctrl-D (on an empty line) or end of piped input: quit. // Ctrl-D (on an empty line) or end of piped input: quit.
Err(ReadlineError::Eof) => break, Err(ReadlineError::Eof) => break,
// Any other read error (e.g. a non-UTF-8 input line) is reported but
// does not end the session — a stray pasted byte shouldn't kill the
// REPL. Bail only if errors keep coming with no successful read in
// between, so a permanently broken stream can't spin forever.
Err(err) => { Err(err) => {
eprintln!("{err}"); eprintln!("{err}");
buffer.clear();
consecutive_errors += 1;
if consecutive_errors >= 100 {
break; break;
} }
} }
} }
}
if let Some(path) = &history_path { if let Some(path) = &history_path {
let _ = editor.save_history(path); let _ = editor.save_history(path);

@ -54,13 +54,18 @@ impl CookieJar {
return; return;
} }
let mut domain = host.to_ascii_lowercase(); let request_host = host.to_ascii_lowercase();
let mut domain = request_host.clone();
for seg in segments { for seg in segments {
if let Some((k, v)) = seg.split_once('=') if let Some((k, v)) = seg.split_once('=')
&& k.trim().eq_ignore_ascii_case("domain") && k.trim().eq_ignore_ascii_case("domain")
{ {
let d = v.trim().trim_start_matches('.').to_ascii_lowercase(); let d = v.trim().trim_start_matches('.').to_ascii_lowercase();
if !d.is_empty() { // Only accept a Domain the request host actually belongs to
// (exact match or a subdomain). Otherwise a response could plant
// a cookie scoped to an unrelated domain ("cookie tossing"); such
// an attribute is ignored and the cookie stays host-scoped.
if !d.is_empty() && (request_host == d || request_host.ends_with(&format!(".{d}"))) {
domain = d; domain = d;
} }
} }
@ -141,6 +146,17 @@ fn form_urlencode(pairs: &[(String, String)]) -> String {
const MAX_REDIRECTS: u32 = 10; const MAX_REDIRECTS: u32 = 10;
/// The origin of a URL: (scheme, host, effective port). Two URLs share an origin
/// when all three match — the boundary across which credentials and secret
/// headers must not be replayed on a redirect.
fn origin_of(u: &reqwest::Url) -> (String, Option<String>, Option<u16>) {
(
u.scheme().to_ascii_lowercase(),
u.host_str().map(|h| h.to_ascii_lowercase()),
u.port_or_known_default(),
)
}
/// Shared by the stateless `http.request` and a session's `:request`. When `jar` /// Shared by the stateless `http.request` and a session's `:request`. When `jar`
/// is `Some`, the matching jar cookies are sent and any `Set-Cookie` responses /// is `Some`, the matching jar cookies are sent and any `Set-Cookie` responses
/// are stored back. /// are stored back.
@ -228,40 +244,64 @@ async fn execute_request(
} }
} }
// Did the caller supply their own Content-Type? If so it wins over the one a
// json/form body would otherwise imply (no silent override, no duplicate).
let has_user_content_type = custom_headers
.iter()
.any(|(k, _)| k.eq_ignore_ascii_case("content-type"));
let is_digest = matches!(auth.as_ref(), Some((true, _, _))); let is_digest = matches!(auth.as_ref(), Some((true, _, _)));
let mut digest_header: Option<String> = None; let mut digest_header: Option<String> = None;
let mut digest_tried = false; let mut digest_tried = false;
let mut url = url; let mut url = url;
let mut redirects_left = MAX_REDIRECTS; let mut redirects_left = MAX_REDIRECTS;
// Origin (scheme, host, port) of the ORIGINAL request. Once a redirect
// leaves this origin we stop attaching credentials, custom headers, and
// per-request cookies, mirroring what reqwest's own redirect policy does:
// a redirect to an attacker-controlled host must not receive the caller's
// Authorization, Cookie, or secret headers.
let origin = reqwest::Url::parse(&url).ok().map(|u| origin_of(&u));
let resp = loop { let resp = loop {
// Host used for cookie matching and as the Set-Cookie domain fallback; // Host used for cookie matching and as the Set-Cookie domain fallback;
// recomputed each hop since a redirect may cross hosts. // recomputed each hop since a redirect may cross hosts.
let host = reqwest::Url::parse(&url) let parsed = reqwest::Url::parse(&url).ok();
.ok() let host = parsed
.as_ref()
.and_then(|u| u.host_str().map(|h| h.to_ascii_lowercase())); .and_then(|u| u.host_str().map(|h| h.to_ascii_lowercase()));
// Are we still on the origin the request was addressed to?
let same_origin = match (origin.as_ref(), parsed.as_ref()) {
(Some(o), Some(u)) => *o == origin_of(u),
_ => false,
};
let mut req = client.request(method.clone(), &url); let mut req = client.request(method.clone(), &url);
if let Some(t) = timeout { if let Some(t) = timeout {
req = req.timeout(t); req = req.timeout(t);
} }
// Custom headers carry only while on the original origin.
if same_origin {
for (k, v) in &custom_headers { for (k, v) in &custom_headers {
req = req.header(k, v); req = req.header(k, v);
} }
}
// Cookie header: jar cookies for this host, then per-request cookies // Cookie header: jar cookies for this host (the jar is domain-scoped, so
// which override on name collision. // this is always safe), then per-request cookies which override on name
// collision — but the explicit per-request cookies stay on-origin only.
let mut cookie_map: HashMap<String, String> = HashMap::new(); let mut cookie_map: HashMap<String, String> = HashMap::new();
if let (Some(jar), Some(host)) = (jar.as_ref(), host.as_ref()) { if let (Some(jar), Some(host)) = (jar.as_ref(), host.as_ref()) {
for (n, v) in jar.lock().unwrap().cookies_for(host) { for (n, v) in jar.lock().unwrap_or_else(|e| e.into_inner()).cookies_for(host) {
cookie_map.insert(n, v); cookie_map.insert(n, v);
} }
} }
if same_origin {
for (k, v) in &opts_cookies { for (k, v) in &opts_cookies {
cookie_map.insert(k.clone(), v.clone()); cookie_map.insert(k.clone(), v.clone());
} }
}
if !cookie_map.is_empty() { if !cookie_map.is_empty() {
let header = cookie_map let header = cookie_map
.iter() .iter()
@ -272,15 +312,23 @@ async fn execute_request(
} }
if let Some((bytes, ct)) = body.as_ref() { if let Some((bytes, ct)) = body.as_ref() {
if let Some(ct) = ct { // Apply the body's implied Content-Type only when the caller didn't
// set their own (and theirs is still in effect, i.e. same origin).
if let Some(ct) = ct
&& !(same_origin && has_user_content_type)
{
req = req.header(reqwest::header::CONTENT_TYPE, *ct); req = req.header(reqwest::header::CONTENT_TYPE, *ct);
} }
req = req.body(bytes.clone()); req = req.body(bytes.clone());
} }
// Auth. Basic goes out on every hop; digest's Authorization is set only // Auth. Basic goes out on each hop; digest's Authorization is set only
// after the 401 challenge below has been answered (digest_header). // after the 401 challenge below has been answered (digest_header). Both
if let Some((digest, username, password)) = auth.as_ref() { // are withheld once a redirect leaves the original origin, so credentials
// never reach a host the caller did not address.
if same_origin
&& let Some((digest, username, password)) = auth.as_ref()
{
if *digest { if *digest {
if let Some(h) = digest_header.as_ref() { if let Some(h) = digest_header.as_ref() {
req = req.header(reqwest::header::AUTHORIZATION, h); req = req.header(reqwest::header::AUTHORIZATION, h);
@ -298,7 +346,7 @@ async fn execute_request(
// Store Set-Cookie from this hop into the jar. // Store Set-Cookie from this hop into the jar.
if let (Some(jar), Some(host)) = (jar.as_ref(), host.as_ref()) { if let (Some(jar), Some(host)) = (jar.as_ref(), host.as_ref()) {
let mut j = jar.lock().unwrap(); let mut j = jar.lock().unwrap_or_else(|e| e.into_inner());
for value in resp.headers().get_all(reqwest::header::SET_COOKIE).iter() { for value in resp.headers().get_all(reqwest::header::SET_COOKIE).iter() {
if let Ok(s) = value.to_str() { if let Ok(s) = value.to_str() {
j.set_from_header(host, s); j.set_from_header(host, s);
@ -339,15 +387,21 @@ async fn execute_request(
} }
} }
// Follow a redirect if there is one to follow. // Follow a redirect if there is one to follow. A redirect with a usable
// Location that we can't follow because the budget is spent is a "too
// many redirects" error, not a silent success returning the 3xx.
if status.is_redirection() if status.is_redirection()
&& redirects_left > 0
&& let Some(next) = resp && let Some(next) = resp
.headers() .headers()
.get(reqwest::header::LOCATION) .get(reqwest::header::LOCATION)
.and_then(|l| l.to_str().ok()) .and_then(|l| l.to_str().ok())
.and_then(|loc| reqwest::Url::parse(&url).and_then(|base| base.join(loc)).ok()) .and_then(|loc| reqwest::Url::parse(&url).and_then(|base| base.join(loc)).ok())
{ {
if redirects_left == 0 {
return Err(mlua::Error::external(format!(
"http: too many redirects (max {MAX_REDIRECTS})"
)));
}
redirects_left -= 1; redirects_left -= 1;
// 303, and 301/302 on a POST, degrade to a bodyless GET — the // 303, and 301/302 on a POST, degrade to a bodyless GET — the
// behaviour browsers and reqwest's own redirect policy apply. // behaviour browsers and reqwest's own redirect policy apply.
@ -420,7 +474,7 @@ fn make_session(
tbl.raw_set("save", { tbl.raw_set("save", {
let jar = jar.clone(); let jar = jar.clone();
lua.create_function(move |_, (_this, path): (LuaTable, String)| { lua.create_function(move |_, (_this, path): (LuaTable, String)| {
let jsonl = jar.lock().unwrap().to_jsonl(); let jsonl = jar.lock().unwrap_or_else(|e| e.into_inner()).to_jsonl();
std::fs::write(&path, jsonl) std::fs::write(&path, jsonl)
.map_err(|e| mlua::Error::external(format!("session:save: {e}"))) .map_err(|e| mlua::Error::external(format!("session:save: {e}")))
})? })?
@ -431,7 +485,7 @@ fn make_session(
lua.create_function(move |_, (_this, path): (LuaTable, String)| { lua.create_function(move |_, (_this, path): (LuaTable, String)| {
let src = std::fs::read_to_string(&path) let src = std::fs::read_to_string(&path)
.map_err(|e| mlua::Error::external(format!("session:load: {e}")))?; .map_err(|e| mlua::Error::external(format!("session:load: {e}")))?;
jar.lock().unwrap().merge_jsonl(&src); jar.lock().unwrap_or_else(|e| e.into_inner()).merge_jsonl(&src);
Ok(()) Ok(())
})? })?
})?; })?;
@ -439,7 +493,7 @@ fn make_session(
tbl.raw_set("clearCookies", { tbl.raw_set("clearCookies", {
let jar = jar.clone(); let jar = jar.clone();
lua.create_function(move |_, _this: LuaTable| { lua.create_function(move |_, _this: LuaTable| {
jar.lock().unwrap().cookies.clear(); jar.lock().unwrap_or_else(|e| e.into_inner()).cookies.clear();
Ok(()) Ok(())
})? })?
})?; })?;
@ -448,7 +502,7 @@ fn make_session(
let jar = jar.clone(); let jar = jar.clone();
lua.create_function(move |lua, _this: LuaTable| { lua.create_function(move |lua, _this: LuaTable| {
let outer = lua.create_table()?; let outer = lua.create_table()?;
let j = jar.lock().unwrap(); let j = jar.lock().unwrap_or_else(|e| e.into_inner());
for (domain, names) in j.cookies.iter() { for (domain, names) in j.cookies.iter() {
let inner = lua.create_table()?; let inner = lua.create_table()?;
for (name, value) in names.iter() { for (name, value) in names.iter() {
@ -527,3 +581,71 @@ pub(super) fn install(lua: &Lua) -> LuaResult<()> {
// session metatable wrapping http.session(). // session metatable wrapping http.session().
lua.load(HTTP_LUA).set_name("@[stdlib/http]").exec() lua.load(HTTP_LUA).set_name("@[stdlib/http]").exec()
} }
#[cfg(test)]
mod tests {
use super::*;
fn cookie(jar: &CookieJar, domain: &str, name: &str) -> Option<String> {
jar.cookies.get(domain).and_then(|m| m.get(name).cloned())
}
#[test]
fn set_cookie_defaults_to_request_host() {
let mut jar = CookieJar::default();
jar.set_from_header("example.com", "sid=abc");
assert_eq!(cookie(&jar, "example.com", "sid").as_deref(), Some("abc"));
}
#[test]
fn set_cookie_honors_domain_the_host_belongs_to() {
let mut jar = CookieJar::default();
// A subdomain may scope a cookie up to its parent domain.
jar.set_from_header("app.example.com", "sid=abc; Domain=example.com");
assert_eq!(cookie(&jar, "example.com", "sid").as_deref(), Some("abc"));
}
#[test]
fn set_cookie_rejects_foreign_domain() {
let mut jar = CookieJar::default();
// Cookie tossing: a response from one host must not plant a cookie scoped
// to an unrelated domain. The bad Domain= is ignored; it stays host-scoped.
jar.set_from_header("127.0.0.1", "evil=1; Domain=other.example");
assert!(cookie(&jar, "other.example", "evil").is_none());
assert_eq!(cookie(&jar, "127.0.0.1", "evil").as_deref(), Some("1"));
}
#[test]
fn set_cookie_rejects_partial_suffix_match() {
let mut jar = CookieJar::default();
// "notexample.com" ends with "example.com" as a raw substring but is not
// a subdomain, so the Domain must be rejected.
jar.set_from_header("notexample.com", "x=1; Domain=example.com");
assert!(cookie(&jar, "example.com", "x").is_none());
assert_eq!(cookie(&jar, "notexample.com", "x").as_deref(), Some("1"));
}
#[test]
fn origins_compare_by_scheme_host_port() {
let u = |s: &str| origin_of(&reqwest::Url::parse(s).unwrap());
assert_eq!(u("https://a.com/x"), u("https://a.com/y")); // path-independent
assert_eq!(u("https://a.com"), u("https://a.com:443")); // default port
assert_ne!(u("https://a.com"), u("http://a.com")); // scheme differs
assert_ne!(u("https://a.com"), u("https://b.com")); // host differs
assert_ne!(u("https://a.com"), u("https://a.com:8443")); // port differs
}
#[test]
fn cookies_for_matches_domain_and_subdomains() {
let mut jar = CookieJar::default();
jar.set_from_header("example.com", "a=1");
assert_eq!(jar.cookies_for("example.com"), vec![("a".into(), "1".into())]);
// a cookie stored on example.com is sent to its subdomains
jar.set_from_header("sub.example.com", "b=2; Domain=example.com");
let mut got = jar.cookies_for("sub.example.com");
got.sort();
assert_eq!(got, vec![("a".into(), "1".into()), ("b".into(), "2".into())]);
// but not to an unrelated host
assert!(jar.cookies_for("other.com").is_empty());
}
}

@ -5,7 +5,17 @@ fn args_to_string(lua: &Lua, args: Variadic<LuaValue>) -> LuaResult<String> {
let tostring: mlua::Function = lua.globals().get("tostring")?; let tostring: mlua::Function = lua.globals().get("tostring")?;
let parts: Vec<String> = args let parts: Vec<String> = args
.into_iter() .into_iter()
.map(|v| tostring.call::<String>(v).unwrap_or_else(|_| "?".to_string())) .map(|v| {
// tostring can yield a non-UTF-8 byte string (Lua strings are byte
// strings); render it lossily rather than collapsing the whole
// argument to "?" and losing its readable parts. "?" is reserved for
// the rare case where tostring itself errors (e.g. a broken
// __tostring metamethod).
match tostring.call::<mlua::String>(v) {
Ok(s) => s.to_string_lossy(),
Err(_) => "?".to_string(),
}
})
.collect(); .collect();
Ok(parts.join("\t")) Ok(parts.join("\t"))
} }
@ -55,3 +65,28 @@ pub(super) fn install(lua: &Lua) -> LuaResult<()> {
lua.globals().raw_set("log", log)?; lua.globals().raw_set("log", log)?;
Ok(()) Ok(())
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn non_utf8_arg_is_rendered_lossily_not_dropped() {
let lua = Lua::new();
// A byte string that is not valid UTF-8 must keep its readable parts
// (via lossy conversion) rather than collapsing the whole argument to "?".
let args: Variadic<LuaValue> =
Variadic::from_iter([LuaValue::String(lua.create_string(b"\xff readable").unwrap())]);
let rendered = args_to_string(&lua, args).unwrap();
assert!(rendered.contains("readable"), "got: {rendered:?}");
assert_ne!(rendered, "?");
}
#[test]
fn multiple_args_are_tab_joined() {
let lua = Lua::new();
let args: Variadic<LuaValue> =
Variadic::from_iter([LuaValue::Integer(1), LuaValue::Boolean(true)]);
assert_eq!(args_to_string(&lua, args).unwrap(), "1\ttrue");
}
}

@ -0,0 +1,116 @@
use std::borrow::Cow;
use mlua::Value as LuaValue;
use serde_json::Value as JsonValue;
/// Pretty-print a Lua value for humans (the backend of `utils.dump`).
/// Ported from flowbox-rt's fb_lua lua_dump.rs, adapted to mlua 0.11.
pub(super) fn dump(value: LuaValue) -> Cow<'static, str> {
dump_inner(value, DumpPosition::Value, 0)
}
enum DumpPosition {
Key,
Value,
}
fn keywrap(value: Cow<'static, str>, position: DumpPosition) -> Cow<'static, str> {
match position {
DumpPosition::Key => format!("[{value}]").into(),
DumpPosition::Value => value,
}
}
fn dump_inner(value: LuaValue, position: DumpPosition, recursion_depth: usize) -> Cow<'static, str> {
let res: Cow<'static, str> = match value {
LuaValue::Nil => "nil".into(),
LuaValue::Boolean(v) => if v { "true" } else { "false" }.into(),
LuaValue::Integer(v) => v.to_string().into(),
LuaValue::Number(v) => {
if v.fract() == 0.0 {
// ensure decimal places are shown to indicate it is a float
format!("{:.1}", v.trunc()).into()
} else {
v.to_string().into()
}
}
LuaValue::String(v) => {
let stringified = v.to_string_lossy().to_string();
match position {
DumpPosition::Key => {
// return to bypass the keywrap
return if stringified.char_indices().any(|(index, c)| {
if index == 0 {
!c.is_ascii_alphabetic() && c != '_'
} else {
!c.is_ascii_alphanumeric() && c != '_'
}
}) {
// json-serialize the string to add quotes and escapes
format!("[{}]", serde_json::to_string(&JsonValue::String(stringified)).unwrap()).into()
} else {
// string lua key can be used as is {foo="bar"}
stringified.into()
};
}
DumpPosition::Value => serde_json::to_string(&JsonValue::String(stringified)).unwrap().into(),
}
}
LuaValue::Table(t) => {
if recursion_depth > 20 {
// Flow through keywrap so a table used as a key still renders as
// `[<TRUNCATED>]`, consistent with every other key.
return keywrap("<TRUNCATED>".into(), position);
}
let recursion_depth = recursion_depth + 1;
let mut buf = "{".to_string();
let mut first = true;
let mut list_next = Some(1);
for pair in t.pairs::<LuaValue, LuaValue>() {
let Ok((k, v)) = pair else {
list_next = None;
continue;
};
if !first {
buf.push_str(", ");
}
first = false;
// special case for sequential numeric keys, starting at 1
if let Some(list_index) = list_next {
if let LuaValue::Integer(index) = k
&& index == list_index
{
buf.push_str(&dump_inner(v, DumpPosition::Value, recursion_depth));
list_next = Some(list_index + 1);
continue;
}
list_next = None;
}
buf.push_str(&format!(
"{}={}",
dump_inner(k, DumpPosition::Key, recursion_depth),
dump_inner(v, DumpPosition::Value, recursion_depth)
));
}
buf.push('}');
buf.into()
}
// The null pointer lightuserdata is the JSON null sentinel (utils.NULL)
LuaValue::LightUserData(ud) if ud.0.is_null() => "NULL".into(),
// Complex objects can't be displayed
LuaValue::LightUserData(_) => "<LightUserData>".into(),
LuaValue::Function(_) => "<Function>".into(),
LuaValue::Thread(_) => "<Thread>".into(),
LuaValue::UserData(_) => "<UserData>".into(),
LuaValue::Error(e) => format!("<Error:{e}>").into(),
other => format!("<{}>", other.type_name()).into(),
};
keywrap(res, position)
}

@ -1,5 +1,6 @@
mod http; mod http;
mod logging; mod logging;
mod lua_dump;
mod math; mod math;
mod os_ext; mod os_ext;
mod sqlite; mod sqlite;

@ -1,7 +1,7 @@
use std::time::{Duration, SystemTime, UNIX_EPOCH}; use std::time::{Duration, SystemTime, UNIX_EPOCH};
use mlua::prelude::LuaResult; use mlua::prelude::LuaResult;
use mlua::{Lua, Table as LuaTable}; use mlua::{Lua, Table as LuaTable, Value as LuaValue};
/// Extend the already-sandboxed `os` table with non-standard additions. /// Extend the already-sandboxed `os` table with non-standard additions.
pub(super) fn install(lua: &Lua) -> LuaResult<()> { pub(super) fn install(lua: &Lua) -> LuaResult<()> {
@ -21,7 +21,33 @@ pub(super) fn install(lua: &Lua) -> LuaResult<()> {
// os.sleep(seconds) — yields the Lua coroutine via tokio; does not block the thread. // os.sleep(seconds) — yields the Lua coroutine via tokio; does not block the thread.
os.raw_set( os.raw_set(
"sleep", "sleep",
lua.create_async_function(|_, seconds: f64| async move { lua.create_async_function(|_, seconds: LuaValue| async move {
// Accept only a number, with a prefixed message (mlua's own coercion
// would report a bare "error converting ..." for e.g. a string).
let seconds = match seconds {
LuaValue::Integer(i) => i as f64,
LuaValue::Number(n) => n,
other => {
return Err(mlua::Error::external(format!(
"os.sleep: duration must be a number (got {})",
other.type_name()
)));
}
};
// Cap the delay: a huge-but-finite value (e.g. 1e18) would otherwise
// park the task effectively forever with no way to interrupt it from
// Lua, which reads as the runtime hanging rather than the script.
const MAX_SLEEP_SECS: f64 = 24.0 * 60.0 * 60.0; // one day
if !(seconds.is_finite() && seconds >= 0.0) {
return Err(mlua::Error::external(
"os.sleep: duration must be a non-negative finite number of seconds",
));
}
if seconds > MAX_SLEEP_SECS {
return Err(mlua::Error::external(format!(
"os.sleep: refusing to sleep for more than {MAX_SLEEP_SECS} seconds (got {seconds})"
)));
}
let duration = Duration::try_from_secs_f64(seconds).map_err(|_| { let duration = Duration::try_from_secs_f64(seconds).map_err(|_| {
mlua::Error::external( mlua::Error::external(
"os.sleep: duration must be a non-negative finite number of seconds", "os.sleep: duration must be a non-negative finite number of seconds",

@ -130,7 +130,16 @@ fn lua_value_to_sqlite(value: LuaValue) -> LuaResult<Value> {
LuaValue::Boolean(b) => Ok(Value::Integer(b as i64)), LuaValue::Boolean(b) => Ok(Value::Integer(b as i64)),
LuaValue::Integer(i) => Ok(Value::Integer(i)), LuaValue::Integer(i) => Ok(Value::Integer(i)),
LuaValue::Number(f) => Ok(Value::Real(f)), LuaValue::Number(f) => Ok(Value::Real(f)),
LuaValue::String(s) => Ok(Value::Text(s.to_str()?.to_owned())), // Lua strings are byte strings. Valid UTF-8 binds as TEXT; anything else
// (raw bytes, embedded NULs) binds as a BLOB so binary data round-trips
// instead of failing the UTF-8 conversion.
LuaValue::String(s) => {
let bytes = s.as_bytes();
match std::str::from_utf8(&bytes) {
Ok(text) => Ok(Value::Text(text.to_owned())),
Err(_) => Ok(Value::Blob(bytes.to_vec())),
}
}
other => Err(mlua::Error::external(format!( other => Err(mlua::Error::external(format!(
"sqlite: cannot bind value of type '{}'", "sqlite: cannot bind value of type '{}'",
other.type_name() other.type_name()
@ -224,11 +233,37 @@ fn rows_to_lua(lua: &Lua, rows: Vec<Record>) -> LuaResult<LuaTable> {
// Blocking workers (run on the tokio blocking pool) // Blocking workers (run on the tokio blocking pool)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Reject SQL that is empty/comment-only or contains more than one statement.
///
/// `execute`/`query`/`queryOne` compile a single statement via `prepare_cached`,
/// which silently ignores anything after the first — so `execute("A; B")` would
/// run only `A` and drop `B` with no error, and `execute("")` surfaces SQLite's
/// bare "not an error". `Batch` uses SQLite's own tokenizer to count statements
/// (skipping comments/whitespace), giving a clear message instead. Use
/// `executeBatch` to run multiple statements on purpose.
fn ensure_single_statement(conn: &rusqlite::Connection, sql: &str) -> Result<(), DbError> {
let mut batch = rusqlite::Batch::new(conn, sql);
let mut count = 0usize;
while batch.next()?.is_some() {
count += 1;
if count > 1 {
return Err(DbError::Other(
"sqlite: expected a single SQL statement (use executeBatch for multiple)".to_string(),
));
}
}
if count == 0 {
return Err(DbError::Other("sqlite: no SQL statement to run".to_string()));
}
Ok(())
}
/// Run an INSERT/UPDATE/DELETE-style statement, returning rows changed. /// Run an INSERT/UPDATE/DELETE-style statement, returning rows changed.
async fn run_execute(handle: ConnHandle, sql: String, params: BoundParams) -> Result<usize, DbError> { async fn run_execute(handle: ConnHandle, sql: String, params: BoundParams) -> Result<usize, DbError> {
tokio::task::spawn_blocking(move || -> Result<usize, DbError> { tokio::task::spawn_blocking(move || -> Result<usize, DbError> {
let guard = handle.lock().unwrap_or_else(|e| e.into_inner()); let guard = handle.lock().unwrap_or_else(|e| e.into_inner());
let conn = guard.as_ref().ok_or(DbError::Closed)?; let conn = guard.as_ref().ok_or(DbError::Closed)?;
ensure_single_statement(conn, &sql)?;
let mut stmt = conn.prepare_cached(&sql)?; let mut stmt = conn.prepare_cached(&sql)?;
bind_params(&mut stmt, &params)?; bind_params(&mut stmt, &params)?;
Ok(stmt.raw_execute()?) Ok(stmt.raw_execute()?)
@ -237,11 +272,24 @@ async fn run_execute(handle: ConnHandle, sql: String, params: BoundParams) -> Re
.unwrap_or_else(|e| Err(DbError::Other(format!("sqlite: background task failed: {e}")))) .unwrap_or_else(|e| Err(DbError::Other(format!("sqlite: background task failed: {e}"))))
} }
/// Run every statement in `sql` in order, with no parameter binding.
async fn run_execute_batch(handle: ConnHandle, sql: String) -> Result<(), DbError> {
tokio::task::spawn_blocking(move || -> Result<(), DbError> {
let guard = handle.lock().unwrap_or_else(|e| e.into_inner());
let conn = guard.as_ref().ok_or(DbError::Closed)?;
conn.execute_batch(&sql)?;
Ok(())
})
.await
.unwrap_or_else(|e| Err(DbError::Other(format!("sqlite: background task failed: {e}"))))
}
/// Run a SELECT-style statement, materializing every row. /// Run a SELECT-style statement, materializing every row.
async fn run_query(handle: ConnHandle, sql: String, params: BoundParams) -> Result<Vec<Record>, DbError> { async fn run_query(handle: ConnHandle, sql: String, params: BoundParams) -> Result<Vec<Record>, DbError> {
tokio::task::spawn_blocking(move || -> Result<Vec<Record>, DbError> { tokio::task::spawn_blocking(move || -> Result<Vec<Record>, DbError> {
let guard = handle.lock().unwrap_or_else(|e| e.into_inner()); let guard = handle.lock().unwrap_or_else(|e| e.into_inner());
let conn = guard.as_ref().ok_or(DbError::Closed)?; let conn = guard.as_ref().ok_or(DbError::Closed)?;
ensure_single_statement(conn, &sql)?;
let mut stmt = conn.prepare_cached(&sql)?; let mut stmt = conn.prepare_cached(&sql)?;
// Column names must be captured before the mutable borrows below. // Column names must be captured before the mutable borrows below.
let columns: Vec<String> = stmt.column_names().into_iter().map(String::from).collect(); let columns: Vec<String> = stmt.column_names().into_iter().map(String::from).collect();
@ -279,6 +327,14 @@ impl UserData for SqliteConn {
} }
}); });
methods.add_async_method("executeBatch", |_, this, sql: String| {
let handle = this.0.clone();
async move {
run_execute_batch(handle, sql).await.map_err(mlua::Error::external)?;
Ok(())
}
});
methods.add_async_method("query", |lua, this, (sql, params): (String, Option<LuaTable>)| { methods.add_async_method("query", |lua, this, (sql, params): (String, Option<LuaTable>)| {
let handle = this.0.clone(); let handle = this.0.clone();
let bound = convert_params(params); let bound = convert_params(params);
@ -301,23 +357,49 @@ impl UserData for SqliteConn {
} }
}); });
// These only read in-memory connection state, so they stay synchronous. // These only read in-memory connection state, but they still contend on
methods.add_method("lastInsertRowid", |_, this, ()| { // the same mutex that a running query holds for its whole duration. Doing
let guard = this.0.lock().unwrap_or_else(|e| e.into_inner()); // the lock on the blocking pool (not inline) keeps the tokio event loop
let conn = guard.as_ref().ok_or_else(|| mlua::Error::external(DbError::Closed))?; // responsive: a `close()` next to a long query no longer freezes every
Ok(conn.last_insert_rowid()) // other coroutine and timer.
methods.add_async_method("lastInsertRowid", |_, this, ()| {
let handle = this.0.clone();
async move {
tokio::task::spawn_blocking(move || -> Result<i64, DbError> {
let guard = handle.lock().unwrap_or_else(|e| e.into_inner());
Ok(guard.as_ref().ok_or(DbError::Closed)?.last_insert_rowid())
})
.await
.unwrap_or_else(|e| Err(DbError::Other(format!("sqlite: background task failed: {e}"))))
.map_err(mlua::Error::external)
}
}); });
methods.add_method("changes", |_, this, ()| { methods.add_async_method("changes", |_, this, ()| {
let guard = this.0.lock().unwrap_or_else(|e| e.into_inner()); let handle = this.0.clone();
let conn = guard.as_ref().ok_or_else(|| mlua::Error::external(DbError::Closed))?; async move {
Ok(conn.changes() as i64) tokio::task::spawn_blocking(move || -> Result<i64, DbError> {
let guard = handle.lock().unwrap_or_else(|e| e.into_inner());
Ok(guard.as_ref().ok_or(DbError::Closed)?.changes() as i64)
})
.await
.unwrap_or_else(|e| Err(DbError::Other(format!("sqlite: background task failed: {e}"))))
.map_err(mlua::Error::external)
}
}); });
methods.add_method("close", |_, this, ()| { methods.add_async_method("close", |_, this, ()| {
let handle = this.0.clone();
async move {
// Dropping the connection releases the SQLite handle; later use errors. // Dropping the connection releases the SQLite handle; later use errors.
*this.0.lock().unwrap_or_else(|e| e.into_inner()) = None; // Done on the blocking pool so it waits behind an in-flight query
Ok(()) // there rather than stalling the event loop.
tokio::task::spawn_blocking(move || {
*handle.lock().unwrap_or_else(|e| e.into_inner()) = None;
})
.await
.map_err(|e| mlua::Error::external(format!("sqlite: background task failed: {e}")))
}
}); });
} }
} }
@ -331,8 +413,13 @@ pub(super) fn install(lua: &Lua) -> LuaResult<()> {
sqlite.set( sqlite.set(
"connect", "connect",
lua.create_function(|_, path: String| { lua.create_async_function(|_, path: String| async move {
let conn = rusqlite::Connection::open(&path).map_err(mlua::Error::external)?; // Opening a file-backed database touches disk, so do it on the
// blocking pool rather than the tokio event loop.
let conn = tokio::task::spawn_blocking(move || rusqlite::Connection::open(&path))
.await
.map_err(|e| mlua::Error::external(format!("sqlite.connect: background task failed: {e}")))?
.map_err(|e| mlua::Error::external(format!("sqlite.connect: {e}")))?;
Ok(SqliteConn(Arc::new(Mutex::new(Some(conn))))) Ok(SqliteConn(Arc::new(Mutex::new(Some(conn)))))
})?, })?,
)?; )?;

@ -2,6 +2,26 @@ use futures::future::join_all;
use mlua::prelude::{LuaResult, LuaValue}; use mlua::prelude::{LuaResult, LuaValue};
use mlua::{Function, Lua, MultiValue, Variadic}; use mlua::{Function, Lua, MultiValue, Variadic};
/// Validate that every variadic argument is a function, returning them as a
/// `Vec<Function>`. Produces a `task.join:`-prefixed error naming the offending
/// argument position and type, instead of mlua's bare conversion message.
fn functions_or_error(args: Variadic<LuaValue>) -> LuaResult<Vec<Function>> {
let mut out = Vec::with_capacity(args.len());
for (i, v) in args.into_iter().enumerate() {
match v {
LuaValue::Function(f) => out.push(f),
other => {
return Err(mlua::Error::external(format!(
"task.join: argument #{} must be a function (got {})",
i + 1,
other.type_name()
)))
}
}
}
Ok(out)
}
/// Proof-of-concept concurrency primitive built on Lua coroutines + tokio. /// Proof-of-concept concurrency primitive built on Lua coroutines + tokio.
/// ///
/// `task.join(f1, f2, ...)` runs each function as its own Lua coroutine and /// `task.join(f1, f2, ...)` runs each function as its own Lua coroutine and
@ -19,7 +39,8 @@ pub(super) fn install(lua: &Lua) -> LuaResult<()> {
task.raw_set( task.raw_set(
"join", "join",
lua.create_async_function(|lua, funcs: Variadic<Function>| async move { lua.create_async_function(|lua, args: Variadic<LuaValue>| async move {
let funcs = functions_or_error(args)?;
// Wrap each function in its own coroutine and turn it into a future. // Wrap each function in its own coroutine and turn it into a future.
let mut threads = Vec::with_capacity(funcs.len()); let mut threads = Vec::with_capacity(funcs.len());
for f in funcs.iter() { for f in funcs.iter() {

@ -1,20 +1,12 @@
use std::ffi::c_void;
use mlua::prelude::LuaResult; use mlua::prelude::LuaResult;
use mlua::{LightUserData, Lua, Value as LuaValue}; use mlua::{Lua, Value as LuaValue};
use serde_json;
const UTILS_LUA: &str = include_str!("../../lua/stdlib/utils.lua"); const UTILS_LUA: &str = include_str!("../../lua/stdlib/utils.lua");
// A stable address used as the identity of utils.NULL. // utils.NULL is a null-pointer light userdata - the same sentinel mlua itself
static NULL_MARKER: u8 = 0; // uses for JSON null (mlua::Value::NULL), and the same convention flowbox uses.
fn null_lud() -> LightUserData {
LightUserData(std::ptr::addr_of!(NULL_MARKER) as *mut c_void)
}
pub(crate) fn is_null(val: &LuaValue) -> bool { pub(crate) fn is_null(val: &LuaValue) -> bool {
matches!(val, LuaValue::LightUserData(p) if *p == null_lud()) matches!(val, LuaValue::LightUserData(p) if p.0.is_null())
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -38,7 +30,17 @@ pub(crate) fn lua_to_json(val: LuaValue, depth: u32) -> LuaResult<serde_json::Va
.ok_or_else(|| { .ok_or_else(|| {
mlua::Error::external("utils.toJSON: NaN and Infinity cannot be serialized as JSON") mlua::Error::external("utils.toJSON: NaN and Infinity cannot be serialized as JSON")
}), }),
LuaValue::String(s) => Ok(serde_json::Value::String(s.to_str()?.to_owned())), LuaValue::String(s) => {
// JSON strings must be UTF-8. Lua strings are byte strings, so give a
// clear prefixed error instead of mlua's bare conversion message.
let bytes = s.as_bytes();
match std::str::from_utf8(&bytes) {
Ok(text) => Ok(serde_json::Value::String(text.to_owned())),
Err(_) => Err(mlua::Error::external(
"utils.toJSON: string is not valid UTF-8 and cannot be encoded as JSON",
)),
}
}
LuaValue::Table(t) => table_to_json(t, depth), LuaValue::Table(t) => table_to_json(t, depth),
other => Err(mlua::Error::external(format!( other => Err(mlua::Error::external(format!(
"utils.toJSON: cannot serialize value of type '{}'", "utils.toJSON: cannot serialize value of type '{}'",
@ -48,7 +50,7 @@ pub(crate) fn lua_to_json(val: LuaValue, depth: u32) -> LuaResult<serde_json::Va
} }
pub(crate) fn table_to_json(t: mlua::Table, depth: u32) -> LuaResult<serde_json::Value> { pub(crate) fn table_to_json(t: mlua::Table, depth: u32) -> LuaResult<serde_json::Value> {
let seq_len = t.raw_len() as usize; let seq_len = t.raw_len();
// Try array serialization: every key must be a sequential integer in 1..=seq_len. // Try array serialization: every key must be a sequential integer in 1..=seq_len.
if seq_len > 0 { if seq_len > 0 {
@ -89,6 +91,14 @@ pub(crate) fn table_to_json(t: mlua::Table, depth: u32) -> LuaResult<serde_json:
))) )))
} }
}; };
// Stringifying integer keys can collide with a real string key (e.g.
// both `[1]` and `["1"]`). Silently dropping one would be data loss, so
// fail loudly like every other unrepresentable case here.
if map.contains_key(&key) {
return Err(mlua::Error::external(format!(
"utils.toJSON: duplicate object key '{key}' (an integer key collides with a string key)"
)));
}
map.insert(key, lua_to_json(v, depth + 1)?); map.insert(key, lua_to_json(v, depth + 1)?);
} }
Ok(serde_json::Value::Object(map)) Ok(serde_json::Value::Object(map))
@ -100,7 +110,7 @@ pub(crate) fn table_to_json(t: mlua::Table, depth: u32) -> LuaResult<serde_json:
fn json_to_lua(lua: &Lua, val: serde_json::Value) -> LuaResult<LuaValue> { fn json_to_lua(lua: &Lua, val: serde_json::Value) -> LuaResult<LuaValue> {
match val { match val {
serde_json::Value::Null => Ok(LuaValue::LightUserData(null_lud())), serde_json::Value::Null => Ok(LuaValue::NULL),
serde_json::Value::Bool(b) => Ok(LuaValue::Boolean(b)), serde_json::Value::Bool(b) => Ok(LuaValue::Boolean(b)),
serde_json::Value::Number(n) => { serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() { if let Some(i) = n.as_i64() {
@ -134,13 +144,18 @@ fn json_to_lua(lua: &Lua, val: serde_json::Value) -> LuaResult<LuaValue> {
pub(super) fn install(lua: &Lua) -> LuaResult<()> { pub(super) fn install(lua: &Lua) -> LuaResult<()> {
let utils = lua.create_table()?; let utils = lua.create_table()?;
utils.raw_set("NULL", LuaValue::LightUserData(null_lud()))?; utils.raw_set("NULL", LuaValue::NULL)?;
utils.raw_set( utils.raw_set(
"isNull", "isNull",
lua.create_function(|_, val: LuaValue| Ok(is_null(&val)))?, lua.create_function(|_, val: LuaValue| Ok(is_null(&val)))?,
)?; )?;
utils.raw_set(
"dump",
lua.create_function(|_, val: LuaValue| Ok(super::lua_dump::dump(val).into_owned()))?,
)?;
utils.raw_set( utils.raw_set(
"toJSON", "toJSON",
lua.create_function(|_, (val, pretty): (LuaValue, Option<bool>)| { lua.create_function(|_, (val, pretty): (LuaValue, Option<bool>)| {
@ -166,6 +181,6 @@ pub(super) fn install(lua: &Lua) -> LuaResult<()> {
lua.globals().raw_set("utils", utils)?; lua.globals().raw_set("utils", utils)?;
// Lua side adds: utils.dump, utils.try, utils.tryn // Lua side adds: utils.try, utils.tryn
lua.load(UTILS_LUA).set_name("@[stdlib/utils]").exec() lua.load(UTILS_LUA).set_name("@[stdlib/utils]").exec()
} }

Loading…
Cancel
Save