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. 2
      docs/utils.md
  4. 44
      lua/coroutines-demo.lua
  5. 32
      lua/stdlib/http.lua
  6. 99
      lua/stdlib/math.lua
  7. 15
      lua/stdlib/sqlite.lua
  8. 340
      lua/stdlib/table.lua
  9. 122
      lua/stdlib/utils.lua
  10. 30
      lua/test.lua
  11. 36
      lua/test_sqlite.lua
  12. 313
      src/lua_tests/async_tests.rs
  13. 226
      src/lua_tests/core_tests.rs
  14. 398
      src/lua_tests/math_tests.rs
  15. 41
      src/lua_tests/mod.rs
  16. 173
      src/lua_tests/os_tests.rs
  17. 1497
      src/lua_tests/table_tests.rs
  18. 813
      src/lua_tests/utils_tests.rs
  19. 36
      src/main.rs
  20. 21
      src/repl.rs
  21. 158
      src/stdlib/http.rs
  22. 37
      src/stdlib/logging.rs
  23. 116
      src/stdlib/lua_dump.rs
  24. 1
      src/stdlib/mod.rs
  25. 30
      src/stdlib/os_ext.rs
  26. 117
      src/stdlib/sqlite.rs
  27. 23
      src/stdlib/task.rs
  28. 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` 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
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 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.
- **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.
- **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.
- 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.
- 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

@ -10,7 +10,7 @@ local data = utils.fromJSON('{"name":"Alice","age":30}')
print(data.name, data.age) --> Alice 30
print(utils.toJSON({1, 2, 3})) --> [1,2,3]
print(utils.dump({a = 1, b = {2, 3}})) --> { a = 1, b = { 2, 3 } }
print(utils.dump({a = 1, b = {2, 3}})) --> {a=1, b={2, 3}}
```
## JSON

@ -3,7 +3,9 @@
--
-- 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)")
@ -49,8 +51,9 @@ end)
local t1 = os.microtime()
local first = sleeper() -- returns immediately with the sentinel
print(string.format("first resume returned %s after only %.3fs (did NOT wait 0.5s)",
tostring(first), os.microtime() - t1))
print(
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.")
----------------------------------------------------------------------
@ -69,31 +72,34 @@ local function worker(name, secs)
end
local t2 = os.microtime()
local a, b, c = task.join(
worker("slow", 0.30),
worker("med", 0.20),
worker("fast", 0.10)
)
local a, b, c = task.join(worker("slow", 0.30), worker("med", 0.20), worker("fast", 0.10))
local elapsed = os.microtime() - t2
print(string.format("results: %s, %s, %s", a, b, c))
print(string.format("wall time: %.3fs", elapsed))
print(string.format("sequential would have been ~0.60s; concurrent ~0.30s => %s",
elapsed < 0.45 and "CONCURRENT (overlapped on tokio)" or "serialized?!"))
print(
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")
----------------------------------------------------------------------
-- task.join itself yields, so it composes: a joined task can join again.
local outer = task.join(
function()
local x, y = task.join(
function() os.sleep(0.05); return 21 end,
function() os.sleep(0.05); return 21 end
)
local outer = task.join(function()
local x, y = task.join(function()
os.sleep(0.05)
return 21
end, function()
os.sleep(0.05)
return 21
end)
return x + y
end,
function() os.sleep(0.10); return "sibling" end
)
end, function()
os.sleep(0.10)
return "sibling"
end)
print("nested join result:", outer) -- 42
print("\nAll demos finished.")

@ -5,7 +5,9 @@
-- Attach resp.json() to a response table: parses resp.body via utils.fromJSON.
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
end
@ -17,7 +19,9 @@ end
-- Method shorthands for the stateless module.
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
function http.getJSON(url, opts)
@ -41,12 +45,24 @@ function session_mt:request(method, url, opts)
return wrap_resp(self:_request(method, url, opts))
end
function session_mt:get(url, opts) return self:request("GET", url, opts) end
function session_mt:post(url, opts) return self:request("POST", url, opts) 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:get(url, opts)
return self:request("GET", url, opts)
end
function session_mt:post(url, opts)
return self:request("POST", url, opts)
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)
local resp = self:get(url, opts)

@ -11,8 +11,16 @@ local function roundHalfAwayFromZero(x)
return i
end
--- 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.
--- @brief Round a number to the nearest integer, or to N decimal places.
---
--- 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)
if type(value) ~= "number" then
error("round() called with a non-number value")
@ -34,7 +42,7 @@ function math.round(value, places)
if places == 0 then
return value
end
return value + 0.0
return value + 0.0 -- an integer has no decimals to round, but the contract says float
end
if not math.isFinite(value) then
@ -52,64 +60,113 @@ function math.round(value, places)
local mul = 10.0 ^ places
local scaled = value * mul
if not math.isFinite(scaled) then
-- rounding cannot change a value of this magnitude
return value
end
return roundHalfAwayFromZero(scaled) / mul
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)
if type(value) ~= "number" then
error("Non-number passed to clamp() as value")
end
if min ~= nil and type(min) ~= "number" then
error("Non-number passed to clamp() as minimum")
end
if max ~= nil and type(max) ~= "number" then
error("Non-number passed to clamp() as maximum")
end
if min ~= nil and max ~= nil and min > max then
error("clamp() minimum is greater than maximum")
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
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)
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
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)
if type(value) ~= "number" then
error("Non-number passed to sign()")
end
if value > 0 then return 1
elseif value < 0 then return -1
else return 0
if value > 0 then
return 1
elseif value < 0 then
return -1
else
return 0
end
end
--- Scale a value linearly from [inMin, inMax] to [outMin, outMax].
--- If clamp is true, the result is clamped to the output range.
--- @brief Scale a value from one range to another (linear interpolation).
--- @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)
if type(value) ~= "number" then error("Non-number passed to scale() as value") 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
if type(value) ~= "number" then
error("Non-number passed to scale() as value")
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 result = outMin + ratio * (outMax - outMin)
if clamp then
local lo, hi = outMin, outMax
if lo > hi then lo, hi = hi, lo end
if result < lo then return lo
elseif result > hi then return hi
if lo > hi then
lo, hi = hi, lo
end
if result < lo then
return lo
elseif result > hi then
return hi
end
end
return result
end

@ -23,18 +23,21 @@ return function(Connection)
end
--- 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)
if type(fn) ~= "function" then
error("sqlite: transaction requires a function argument (got " .. type(fn) .. ")", 2)
end
self:begin()
local ok, err = pcall(fn)
if ok then
local results = table.pack(pcall(fn))
if results[1] then
self:commit()
else
self:rollback()
error(err, 0)
return table.unpack(results, 2, results.n)
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

@ -1,5 +1,16 @@
--- Make a read-only proxy of a table. Reads pass through; writes raise an error.
--- Caveat: pairs(), next() and # see no contents (proxy is empty). Intended for API namespaces.
--- @brief Make a read-only view of a table.
---
--- 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)
if type(tbl) ~= "table" then
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
error("table.readonly: name is not a string (got " .. type(name) .. ")")
end
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({}, {
__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,
})
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)
if type(tbl) ~= "table" then 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
if type(tbl) ~= "table" then
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 = {}
for k, v in pairs(tbl) do
if predicate(v) then result[k] = v end
if predicate(v) then
result[k] = v
end
end
return result
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)
if type(tbl) ~= "table" then error("table.ifilter: 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
if type(tbl) ~= "table" then
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 = {}
for i = 1, #tbl do
local v = tbl[i]
if predicate(v) then result[#result + 1] = v end
if predicate(v) then
result[#result + 1] = v
end
end
return result
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)
if type(tbl) ~= "table" then 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
if type(tbl) ~= "table" then
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 = {}
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
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)
if type(tbl) ~= "table" then 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
if type(tbl) ~= "table" then
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 = {}
for k, v in pairs(tbl) do
local res = mapper(v)
if res ~= nil then result[k] = res end
if res ~= nil then
result[k] = res
end
end
return result
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)
if type(tbl) ~= "table" then error("table.ifilterMap: 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
if type(tbl) ~= "table" then
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 = {}
for i = 1, #tbl do
local res = mapper(tbl[i])
if res ~= nil then result[#result + 1] = res end
if res ~= nil then
result[#result + 1] = res
end
end
return result
end
--- Merge tables into a new table. Sequence parts are appended in order; other keys are
--- copied with later arguments overwriting earlier ones. Not recursive; not deep.
--- @brief Merge tables into a new table. The sequence part (consecutive positive integer keys
--- 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(...)
local result = {}
local n = 0
@ -100,18 +170,27 @@ end
local MERGE_DEPTH_LIMIT = 100
local function deepCopy(value, level)
if type(value) ~= "table" then return value end
if level > MERGE_DEPTH_LIMIT then error("table.deepMerge/deepCopy recursion too deep") end
if type(value) ~= "table" then
return value
end
if level > MERGE_DEPTH_LIMIT then
error("table.deepMerge/deepCopy recursion too deep")
end
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
end
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 n = 0
for argi = 1, select('#', ...) do
for argi = 1, select("#", ...) do
local tbl = select(argi, ...)
if type(tbl) ~= "table" then
error("table.deepMerge: argument #" .. argi .. " is not a table (got " .. type(tbl) .. ")")
@ -135,126 +214,235 @@ local function mergeRecurse(level, ...)
return result
end
--- Recursively merge tables. Nested tables on both sides are merged; other values are overwritten.
--- The result shares no tables with the inputs (everything is deep-copied).
--- @brief Merge tables recursively into a new table. Sequence parts are appended in argument
--- 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(...)
return mergeRecurse(1, ...)
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)
if type(value) ~= "table" then
error("table.deepCopy: argument is not a table (got " .. type(value) .. ")")
end
return deepCopy(value, 1)
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)
if type(tbl) ~= "table" then 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
if type(tbl) ~= "table" then
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
for _, v in pairs(tbl) do accumulator = reducer(accumulator, v) end
for _, v in pairs(tbl) do
accumulator = reducer(accumulator, v)
end
return accumulator
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)
if type(tbl) ~= "table" then 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
if type(tbl) ~= "table" then
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
for _, v in ipairs(tbl) do accumulator = reducer(accumulator, v) end
for _, v in ipairs(tbl) do
accumulator = reducer(accumulator, v)
end
return accumulator
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)
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
for _, val in pairs(tbl) do
if val ~= val then return val end
if min == nil or val < min then min = val end
if val ~= val then
return val -- NaN propagates
end
if min == nil or val < min then
min = val
end
end
return min
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)
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
for _, val in pairs(tbl) do
if val ~= val then return val end
if max == nil or val > max then max = val end
if val ~= val then
return val -- NaN propagates
end
if max == nil or val > max then
max = val
end
end
return max
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)
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 count = 0
for _, val in pairs(tbl) do
count = count + 1
sum = sum and sum + val or val
if sum == nil then
sum = val
else
sum = sum + val
end
end
return count > 0 and sum / count or nil
if count == 0 then
return nil
end
return sum / count
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)
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
for _, val in pairs(tbl) do sum = sum + val end
for _, val in pairs(tbl) do
sum = sum + val
end
return sum
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)
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 = {}
for k in pairs(tbl) do result[#result + 1] = k end
for k, _ in pairs(tbl) do
result[#result + 1] = k
end
return result
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)
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 = {}
for _, v in pairs(tbl) do result[#result + 1] = v end
for _, v in pairs(tbl) do
result[#result + 1] = v
end
return result
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)
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
if v == value then return true end
if v == value then
return true
end
end
return false
end
--- Find a value matching a predicate. Returns (value, key) or (nil, nil) if not found.
--- Check the returned key for nil to distinguish a stored false from "not found".
--- @brief: Find a table element that matches a predicate. Search order is unspecified
--- (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)
if type(tbl) ~= "table" then 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
if type(tbl) ~= "table" then
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
if predicate(v) then return v, k end
if predicate(v) then
return v, k
end
end
return nil, nil
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)
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
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)
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 = {}
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
end

@ -1,81 +1,103 @@
-- Lua-side of the utils module.
-- utils.NULL, utils.isNull, utils.toJSON, utils.fromJSON are provided by Rust before this runs.
-- Lua-implemented part of the utils module, extending the global utils table.
-- 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"
-- Pretty-print any Lua value as a string.
local function dumpValue(val, depth, seen)
if depth > 20 then return "..." end
local t = type(val)
if t == "nil" then
return "nil"
elseif t == "boolean" or t == "number" then
return tostring(val)
elseif t == "string" then
return string.format("%q", val)
elseif t == "table" then
if utils.isNull(val) then return "null" end
if seen[val] then return "<circular>" end
seen[val] = true
local parts = {}
local len = #val
if len > 0 then
for i = 1, len do
parts[i] = dumpValue(val[i], depth + 1, seen)
end
else
for k, v in pairs(val) do
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.
--- @brief Call a function, capturing any error - like pcall, but with Go-style returns.
---
--- local value, err = utils.try(callback)
--- local value, err = utils.try(callback, arg1, arg2) -- pcall-style shorthand
--- if err then
--- print(err)
--- else
--- -- handle value
--- end
---
--- - err is nil if the callback succeeded, otherwise the error value
--- - value is the callback's first return value (nil on error)
--- - extra arguments are passed to the callback, like with pcall
---
--- This is less confusing than pcall when the return value is actually needed.
--- The thrown error value is passed through as-is, so a table thrown with
--- error({code = 42}) stays a table. The downside is that the result can't be
--- used directly as a condition in "if" or "while" - simply use pcall there.
---
--- @param callback function: Function to call
--- @param ... any: Arguments passed to the callback
--- @return any, any: The callback's first return value and nil, or nil and the error
function utils.try(callback, ...)
if type(callback) ~= "function" then
error("utils.try: callback is not a function (got " .. type(callback) .. ")")
end
local ok, result = pcall(callback, ...)
if ok then return result, nil end
if result == nil then result = NIL_ERROR_PLACEHOLDER end
if ok then
return result, nil
end
if result == nil then
result = NIL_ERROR_PLACEHOLDER
end
return nil, result
end
local TRYN_MAX_COUNT = 64
--- Like utils.try but for functions that return N values.
--- Returns (v1, ..., vN, nil) on success or (nil, ..., nil, err) on failure.
--- @brief Call a function, capturing any error - like utils.try, but for N return values.
---
--- 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, ...)
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
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
error("utils.tryn: at most " .. TRYN_MAX_COUNT .. " return values are supported")
end
if type(callback) ~= "function" then
error("utils.tryn: callback is not a function (got " .. type(callback) .. ")")
end
local results = table.pack(pcall(callback, ...))
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
return table.unpack(results, 2, expectedCount + 2)
end
local err = results[2]
if err == nil then err = NIL_ERROR_PLACEHOLDER end
if err == nil then
err = NIL_ERROR_PLACEHOLDER
end
local out = {}
out[expectedCount + 1] = err
return table.unpack(out, 1, expectedCount + 1)

@ -4,39 +4,47 @@ print("=== math ===")
print("round(2.5) =", math.round(2.5)) -- 3
print("round(2.567,2) =", math.round(2.567, 2)) -- 2.57
print("clamp(10,0,5) =", math.clamp(10, 0, 5)) -- 5
print("isFinite(1/0) =", math.isFinite(1/0)) -- false
print("isFinite(1/0) =", math.isFinite(1 / 0)) -- false
print("isFinite(3.14) =", math.isFinite(3.14)) -- true
print("sign(-7) =", math.sign(-7)) -- -1
print("scale(5,0,10,0,100) =", math.scale(5, 0, 10, 0, 100)) -- 50.0
print("\n=== table ===")
local t = {3, 1, 4, 1, 5}
local t = { 3, 1, 4, 1, 5 }
print("sum =", table.sum(t)) -- 14
print("min =", table.min(t)) -- 1
print("max =", table.max(t)) -- 5
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
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("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("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("\n=== utils ===")
print("dump(nil) =", utils.dump(nil))
print("dump({1,2}) =", utils.dump({1, 2}))
print("dump({x=1}) =", utils.dump({x = 1}))
print("dump({1,2}) =", utils.dump({ 1, 2 }))
print("dump({x=1}) =", utils.dump({ x = 1 }))
print("isNull(NULL) =", utils.isNull(utils.NULL))
print("isNull(nil) =", utils.isNull(nil))
local json = utils.toJSON({name="Alice", age=30, tags={"a","b"}})
local json = utils.toJSON({ name = "Alice", age = 30, tags = { "a", "b" } })
print("toJSON =", json)
local parsed = utils.fromJSON(json)
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)
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("\n=== os ===")

@ -2,8 +2,10 @@
local function assert_eq(actual, expected, msg)
if actual ~= expected then
error(string.format("%s: expected %s, got %s",
msg or "assertion failed", tostring(expected), tostring(actual)), 2)
error(
string.format("%s: expected %s, got %s", msg or "assertion failed", tostring(expected), tostring(actual)),
2
)
end
end
@ -25,7 +27,7 @@ con:execute([[
-- Positional params, every supported type.
local changed = con:execute(
"INSERT INTO people (name, age, score, vip, notes) VALUES (?, ?, ?, ?, ?)",
{"Alice", 30, 9.5, true, "first"}
{ "Alice", 30, 9.5, true, "first" }
)
assert_eq(changed, 1, "execute returns rows changed")
assert_eq(con:changes(), 1, "changes() after insert")
@ -35,7 +37,7 @@ assert_eq(first_id, 1, "lastInsertRowid")
-- Named params (mix of :name in SQL).
con:execute(
"INSERT INTO people (name, age, score, vip, notes) VALUES (:name, :age, :score, :vip, :notes)",
{name = "Bob", age = 42, score = 7.25, vip = false, notes = utils.NULL}
{ name = "Bob", age = 42, score = 7.25, vip = false, notes = utils.NULL }
)
assert_eq(con:lastInsertRowid(), 2, "lastInsertRowid after second insert")
@ -51,33 +53,33 @@ assert_eq(rows[1].notes, "first", "row 1 notes (text round-trip)")
assert_eq(rows[2].notes, nil, "row 2 notes is nil (NULL round-trip)")
print("=== queryOne ===")
local bob = con:queryOne("SELECT * FROM people WHERE name = ?", {"Bob"})
local bob = con:queryOne("SELECT * FROM people WHERE name = ?", { "Bob" })
assert(bob ~= nil, "queryOne found Bob")
assert_eq(bob.age, 42, "queryOne Bob age")
local missing = con:queryOne("SELECT * FROM people WHERE name = ?", {"Nobody"})
local missing = con:queryOne("SELECT * FROM people WHERE name = ?", { "Nobody" })
assert_eq(missing, nil, "queryOne returns nil for no match")
print("=== UPDATE / DELETE ===")
local n = con:execute("UPDATE people SET age = age + 1 WHERE name = :who", {who = "Alice"})
local n = con:execute("UPDATE people SET age = age + 1 WHERE name = :who", { who = "Alice" })
assert_eq(n, 1, "update affected 1 row")
assert_eq(con:queryOne("SELECT age FROM people WHERE name = ?", {"Alice"}).age, 31, "age incremented")
assert_eq(con:queryOne("SELECT age FROM people WHERE name = ?", { "Alice" }).age, 31, "age incremented")
n = con:execute("DELETE FROM people WHERE name = ?", {"Bob"})
n = con:execute("DELETE FROM people WHERE name = ?", { "Bob" })
assert_eq(n, 1, "delete affected 1 row")
assert_eq(#con:query("SELECT * FROM people"), 1, "one row left after delete")
print("=== transaction(fn) — commit ===")
con:transaction(function()
con:execute("INSERT INTO people (name, age) VALUES (?, ?)", {"Carol", 25})
con:execute("INSERT INTO people (name, age) VALUES (?, ?)", {"Dave", 28})
con:execute("INSERT INTO people (name, age) VALUES (?, ?)", { "Carol", 25 })
con:execute("INSERT INTO people (name, age) VALUES (?, ?)", { "Dave", 28 })
end)
assert_eq(#con:query("SELECT * FROM people"), 3, "two rows committed")
print("=== transaction(fn) — rollback on error ===")
local ok, err = pcall(function()
con:transaction(function()
con:execute("INSERT INTO people (name, age) VALUES (?, ?)", {"Eve", 99})
con:execute("INSERT INTO people (name, age) VALUES (?, ?)", { "Eve", 99 })
error("boom")
end)
end)
@ -87,25 +89,27 @@ assert_eq(#con:query("SELECT * FROM people"), 3, "failed transaction rolled back
print("=== manual begin / commit ===")
con:begin()
con:execute("INSERT INTO people (name, age) VALUES (?, ?)", {"Frank", 50})
con:execute("INSERT INTO people (name, age) VALUES (?, ?)", { "Frank", 50 })
con:commit()
assert_eq(#con:query("SELECT * FROM people"), 4, "manual commit persisted")
print("=== manual begin / rollback ===")
con:begin()
con:execute("INSERT INTO people (name, age) VALUES (?, ?)", {"Grace", 60})
con:execute("INSERT INTO people (name, age) VALUES (?, ?)", { "Grace", 60 })
con:rollback()
assert_eq(#con:query("SELECT * FROM people"), 4, "manual rollback discarded")
print("=== mixed param table is rejected ===")
local mixed_ok = pcall(function()
con:query("SELECT 1 WHERE 1 = ?", {1, key = "x"})
con:query("SELECT 1 WHERE 1 = ?", { 1, key = "x" })
end)
assert_eq(mixed_ok, false, "mixed positional/named params error")
print("=== 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")
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;
// 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 sandbox;
mod stdlib;
@ -16,9 +21,18 @@ struct Args {
}
#[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();
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 lua = sandbox::create_sandboxed_lua()?;
@ -27,15 +41,23 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
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
// skips any first line starting with '#'). The newline is kept so error
// Skip a leading shebang line, if present. The newline is kept so error
// messages still report correct line numbers.
let chunk = if source.starts_with('#') {
&source[source.find('\n').unwrap_or(source.len())..]
let chunk: &[u8] = if source.first() == Some(&b'#') {
let nl = source.iter().position(|&b| b == b'\n').unwrap_or(source.len());
&source[nl..]
} else {
source.as_str()
source
};
lua.load(chunk)

@ -1,16 +1,14 @@
//! Interactive read-eval-print loop.
//!
//! Started when `a` is run with no script path. Each line is compiled and run in
//! the same sandboxed Lua state used for scripts, so the full stdlib (including
//! the async http/sqlite/os.sleep calls) is available. Evaluation uses the async
//! executor, matching how files are run.
//! Each line is compiled and run in exactly the same way scripts are run,
//! keeping the Lua state between lines.
use mlua::prelude::{LuaFunction, LuaMultiValue, LuaTable};
use mlua::Lua;
use rustyline::error::ReadlineError;
use rustyline::DefaultEditor;
const PROMPT: &str = "a> ";
const PROMPT: &str = ">>> ";
const CONTINUATION_PROMPT: &str = "..> ";
/// 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.
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 {
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) {
Ok(line) => {
consecutive_errors = 0;
if buffer.is_empty() {
buffer = line;
} 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.
Err(ReadlineError::Interrupted) => {
buffer.clear();
consecutive_errors = 0;
}
// Ctrl-D (on an empty line) or end of piped input: quit.
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) => {
eprintln!("{err}");
buffer.clear();
consecutive_errors += 1;
if consecutive_errors >= 100 {
break;
}
}
}
}
if let Some(path) = &history_path {
let _ = editor.save_history(path);

@ -54,13 +54,18 @@ impl CookieJar {
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 {
if let Some((k, v)) = seg.split_once('=')
&& k.trim().eq_ignore_ascii_case("domain")
{
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;
}
}
@ -141,6 +146,17 @@ fn form_urlencode(pairs: &[(String, String)]) -> String {
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`
/// is `Some`, the matching jar cookies are sent and any `Set-Cookie` responses
/// 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 mut digest_header: Option<String> = None;
let mut digest_tried = false;
let mut url = url;
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 {
// Host used for cookie matching and as the Set-Cookie domain fallback;
// recomputed each hop since a redirect may cross hosts.
let host = reqwest::Url::parse(&url)
.ok()
let parsed = reqwest::Url::parse(&url).ok();
let host = parsed
.as_ref()
.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);
if let Some(t) = timeout {
req = req.timeout(t);
}
// Custom headers carry only while on the original origin.
if same_origin {
for (k, v) in &custom_headers {
req = req.header(k, v);
}
}
// Cookie header: jar cookies for this host, then per-request cookies
// which override on name collision.
// Cookie header: jar cookies for this host (the jar is domain-scoped, so
// 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();
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);
}
}
if same_origin {
for (k, v) in &opts_cookies {
cookie_map.insert(k.clone(), v.clone());
}
}
if !cookie_map.is_empty() {
let header = cookie_map
.iter()
@ -272,15 +312,23 @@ async fn execute_request(
}
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.body(bytes.clone());
}
// Auth. Basic goes out on every hop; digest's Authorization is set only
// after the 401 challenge below has been answered (digest_header).
if let Some((digest, username, password)) = auth.as_ref() {
// Auth. Basic goes out on each hop; digest's Authorization is set only
// after the 401 challenge below has been answered (digest_header). Both
// 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 let Some(h) = digest_header.as_ref() {
req = req.header(reqwest::header::AUTHORIZATION, h);
@ -298,7 +346,7 @@ async fn execute_request(
// Store Set-Cookie from this hop into the jar.
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() {
if let Ok(s) = value.to_str() {
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()
&& redirects_left > 0
&& let Some(next) = resp
.headers()
.get(reqwest::header::LOCATION)
.and_then(|l| l.to_str().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;
// 303, and 301/302 on a POST, degrade to a bodyless GET — the
// behaviour browsers and reqwest's own redirect policy apply.
@ -420,7 +474,7 @@ fn make_session(
tbl.raw_set("save", {
let jar = jar.clone();
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)
.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)| {
let src = std::fs::read_to_string(&path)
.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(())
})?
})?;
@ -439,7 +493,7 @@ fn make_session(
tbl.raw_set("clearCookies", {
let jar = jar.clone();
lua.create_function(move |_, _this: LuaTable| {
jar.lock().unwrap().cookies.clear();
jar.lock().unwrap_or_else(|e| e.into_inner()).cookies.clear();
Ok(())
})?
})?;
@ -448,7 +502,7 @@ fn make_session(
let jar = jar.clone();
lua.create_function(move |lua, _this: LuaTable| {
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() {
let inner = lua.create_table()?;
for (name, value) in names.iter() {
@ -527,3 +581,71 @@ pub(super) fn install(lua: &Lua) -> LuaResult<()> {
// session metatable wrapping http.session().
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 parts: Vec<String> = args
.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();
Ok(parts.join("\t"))
}
@ -55,3 +65,28 @@ pub(super) fn install(lua: &Lua) -> LuaResult<()> {
lua.globals().raw_set("log", log)?;
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 logging;
mod lua_dump;
mod math;
mod os_ext;
mod sqlite;

@ -1,7 +1,7 @@
use std::time::{Duration, SystemTime, UNIX_EPOCH};
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.
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.raw_set(
"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(|_| {
mlua::Error::external(
"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::Integer(i) => Ok(Value::Integer(i)),
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!(
"sqlite: cannot bind value of type '{}'",
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)
// ---------------------------------------------------------------------------
/// 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.
async fn run_execute(handle: ConnHandle, sql: String, params: BoundParams) -> Result<usize, DbError> {
tokio::task::spawn_blocking(move || -> Result<usize, DbError> {
let guard = handle.lock().unwrap_or_else(|e| e.into_inner());
let conn = guard.as_ref().ok_or(DbError::Closed)?;
ensure_single_statement(conn, &sql)?;
let mut stmt = conn.prepare_cached(&sql)?;
bind_params(&mut stmt, &params)?;
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}"))))
}
/// 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.
async fn run_query(handle: ConnHandle, sql: String, params: BoundParams) -> 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 conn = guard.as_ref().ok_or(DbError::Closed)?;
ensure_single_statement(conn, &sql)?;
let mut stmt = conn.prepare_cached(&sql)?;
// Column names must be captured before the mutable borrows below.
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>)| {
let handle = this.0.clone();
let bound = convert_params(params);
@ -301,23 +357,49 @@ impl UserData for SqliteConn {
}
});
// These only read in-memory connection state, so they stay synchronous.
methods.add_method("lastInsertRowid", |_, this, ()| {
let guard = this.0.lock().unwrap_or_else(|e| e.into_inner());
let conn = guard.as_ref().ok_or_else(|| mlua::Error::external(DbError::Closed))?;
Ok(conn.last_insert_rowid())
// These only read in-memory connection state, but they still contend on
// the same mutex that a running query holds for its whole duration. Doing
// the lock on the blocking pool (not inline) keeps the tokio event loop
// responsive: a `close()` next to a long query no longer freezes every
// 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, ()| {
let guard = this.0.lock().unwrap_or_else(|e| e.into_inner());
let conn = guard.as_ref().ok_or_else(|| mlua::Error::external(DbError::Closed))?;
Ok(conn.changes() as i64)
methods.add_async_method("changes", |_, 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)?.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.
*this.0.lock().unwrap_or_else(|e| e.into_inner()) = None;
Ok(())
// Done on the blocking pool so it waits behind an in-flight query
// 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(
"connect",
lua.create_function(|_, path: String| {
let conn = rusqlite::Connection::open(&path).map_err(mlua::Error::external)?;
lua.create_async_function(|_, path: String| async move {
// 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)))))
})?,
)?;

@ -2,6 +2,26 @@ use futures::future::join_all;
use mlua::prelude::{LuaResult, LuaValue};
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.
///
/// `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(
"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.
let mut threads = Vec::with_capacity(funcs.len());
for f in funcs.iter() {

@ -1,20 +1,12 @@
use std::ffi::c_void;
use mlua::prelude::LuaResult;
use mlua::{LightUserData, Lua, Value as LuaValue};
use serde_json;
use mlua::{Lua, Value as LuaValue};
const UTILS_LUA: &str = include_str!("../../lua/stdlib/utils.lua");
// A stable address used as the identity of utils.NULL.
static NULL_MARKER: u8 = 0;
fn null_lud() -> LightUserData {
LightUserData(std::ptr::addr_of!(NULL_MARKER) as *mut c_void)
}
// utils.NULL is a null-pointer light userdata - the same sentinel mlua itself
// uses for JSON null (mlua::Value::NULL), and the same convention flowbox uses.
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(|| {
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),
other => Err(mlua::Error::external(format!(
"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> {
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.
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)?);
}
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> {
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::Number(n) => {
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<()> {
let utils = lua.create_table()?;
utils.raw_set("NULL", LuaValue::LightUserData(null_lud()))?;
utils.raw_set("NULL", LuaValue::NULL)?;
utils.raw_set(
"isNull",
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(
"toJSON",
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 side adds: utils.dump, utils.try, utils.tryn
// Lua side adds: utils.try, utils.tryn
lua.load(UTILS_LUA).set_name("@[stdlib/utils]").exec()
}

Loading…
Cancel
Save