Rebase lua_tube onto upstream luacontroller

Adds:
 - various bug fixes
 - error label on the formspec
 - lightweight interrupts
This commit is contained in:
groxxda 2020-06-21 20:32:56 +00:00
parent 9eefe9c7bb
commit ee6c9991b9

@ -13,8 +13,10 @@
-- newport = merge_port_states(state1, state2): just does result = state1 or state2 for every port -- newport = merge_port_states(state1, state2): just does result = state1 or state2 for every port
-- set_port(pos, rule, state): activates/deactivates the mesecons according to the port states -- set_port(pos, rule, state): activates/deactivates the mesecons according to the port states
-- set_port_states(pos, ports): Applies new port states to a Luacontroller at pos -- set_port_states(pos, ports): Applies new port states to a Luacontroller at pos
-- run(pos): runs the code in the controller at pos -- run_inner(pos, code, event): runs code on the controller at pos and event
-- reset_meta(pos, code, errmsg): performs a software-reset, installs new code and prints error messages -- reset_formspec(pos, code, errmsg): installs new code and prints error messages, without resetting LCID
-- reset_meta(pos, code, errmsg): performs a software-reset, installs new code and prints error message
-- run(pos, event): a wrapper for run_inner which gets code & handles errors via reset_meta
-- resetn(pos): performs a hardware reset, turns off all ports -- resetn(pos): performs a hardware reset, turns off all ports
-- --
-- The Sandbox -- The Sandbox
@ -168,7 +170,7 @@ local function set_port_states(pos, ports)
-- Solution / Workaround: -- Solution / Workaround:
-- Remember which output was turned off and ignore next "off" event. -- Remember which output was turned off and ignore next "off" event.
local meta = minetest.get_meta(pos) local meta = minetest.get_meta(pos)
local ign = minetest.deserialize(meta:get_string("ignore_offevents")) or {} local ign = minetest.deserialize(meta:get_string("ignore_offevents"), true) or {}
if ports.red and not vports.red and not mesecon.is_powered(pos, rules.red) then ign.red = true end if ports.red and not vports.red and not mesecon.is_powered(pos, rules.red) then ign.red = true end
if ports.blue and not vports.blue and not mesecon.is_powered(pos, rules.blue) then ign.blue = true end if ports.blue and not vports.blue and not mesecon.is_powered(pos, rules.blue) then ign.blue = true end
if ports.yellow and not vports.yellow and not mesecon.is_powered(pos, rules.yellow) then ign.yellow = true end if ports.yellow and not vports.yellow and not mesecon.is_powered(pos, rules.yellow) then ign.yellow = true end
@ -214,7 +216,7 @@ end
local function ignore_event(event, meta) local function ignore_event(event, meta)
if event.type ~= "off" then return false end if event.type ~= "off" then return false end
local ignore_offevents = minetest.deserialize(meta:get_string("ignore_offevents")) or {} local ignore_offevents = minetest.deserialize(meta:get_string("ignore_offevents"), true) or {}
if ignore_offevents[event.pin.name] then if ignore_offevents[event.pin.name] then
ignore_offevents[event.pin.name] = nil ignore_offevents[event.pin.name] = nil
meta:set_string("ignore_offevents", minetest.serialize(ignore_offevents)) meta:set_string("ignore_offevents", minetest.serialize(ignore_offevents))
@ -227,7 +229,11 @@ end
------------------------- -------------------------
local function safe_print(param) local function safe_print(param)
local string_meta = getmetatable("")
local sandbox = string_meta.__index
string_meta.__index = string -- Leave string sandbox temporarily
print(dump(param)) print(dump(param))
string_meta.__index = sandbox -- Restore string sandbox
end end
local function safe_date() local function safe_date()
@ -267,6 +273,7 @@ local function remove_functions(x)
local seen = {} local seen = {}
local function rfuncs(x) local function rfuncs(x)
if x == nil then return end
if seen[x] then return end if seen[x] then return end
seen[x] = true seen[x] = true
if type(x) ~= "table" then return end if type(x) ~= "table" then return end
@ -290,49 +297,174 @@ local function remove_functions(x)
return x return x
end end
local function get_interrupt(pos) -- The setting affects API so is not intended to be changeable at runtime
-- iid = interrupt id local get_interrupt
local function interrupt(time, iid) if mesecon.setting("luacontroller_lightweight_interrupts", false) then
if type(time) ~= "number" then return end -- use node timer
local luac_id = minetest.get_meta(pos):get_int("luac_id") get_interrupt = function(pos, itbl, send_warning)
mesecon.queue:add_action(pos, "pipeworks:lc_tube_interrupt", {luac_id, iid}, time, iid, 1) return (function(time, iid)
if type(time) ~= "number" then error("Delay must be a number") end
if iid ~= nil then send_warning("Interrupt IDs are disabled on this server") end
table.insert(itbl, function() minetest.get_node_timer(pos):start(time) end)
end)
end
else
-- use global action queue
-- itbl: Flat table of functions to run after sandbox cleanup, used to prevent various security hazards
get_interrupt = function(pos, itbl, send_warning)
-- iid = interrupt id
local function interrupt(time, iid)
-- NOTE: This runs within string metatable sandbox, so don't *rely* on anything of the form (""):y
-- Hence the values get moved out. Should take less time than original, so totally compatible
if type(time) ~= "number" then error("Delay must be a number") end
table.insert(itbl, function ()
-- Outside string metatable sandbox, can safely run this now
local luac_id = minetest.get_meta(pos):get_int("luac_id")
-- Check if IID is dodgy, so you can't use interrupts to store an infinite amount of data.
-- Note that this is safe from alter-after-free because this code gets run after the sandbox has ended.
-- This runs outside of the timer and *shouldn't* harm perf. unless dodgy data is being sent in the first place
iid = remove_functions(iid)
local msg_ser = minetest.serialize(iid)
if #msg_ser <= mesecon.setting("luacontroller_interruptid_maxlen", 256) then
mesecon.queue:add_action(pos, "pipeworks:lc_tube_interrupt", {luac_id, iid}, time, iid, 1)
else
send_warning("An interrupt ID was too large!")
end
end)
end
return interrupt
end
end
-- Given a message object passed to digiline_send, clean it up into a form
-- which is safe to transmit over the network and compute its "cost" (a very
-- rough estimate of its memory usage).
--
-- The cleaning comprises the following:
-- 1. Functions (and userdata, though user scripts ought not to get hold of
-- those in the first place) are removed, because they break the model of
-- Digilines as a network that carries basic data, and they could exfiltrate
-- references to mutable objects from one Luacontroller to another, allowing
-- inappropriate high-bandwidth, no-wires communication.
-- 2. Tables are duplicated because, being mutable, they could otherwise be
-- modified after the send is complete in order to change what data arrives
-- at the recipient, perhaps in violation of the previous cleaning rule or
-- in violation of the message size limit.
--
-- The cost indication is only approximate; its not a perfect measurement of
-- the number of bytes of memory used by the message object.
--
-- Parameters:
-- msg -- the message to clean
-- back_references -- for internal use only; do not provide
--
-- Returns:
-- 1. The cleaned object.
-- 2. The approximate cost of the object.
local function clean_and_weigh_digiline_message(msg, back_references)
local t = type(msg)
if t == "string" then
-- Strings are immutable so can be passed by reference, and cost their
-- length plus the size of the Lua object header (24 bytes on a 64-bit
-- platform) plus one byte for the NUL terminator.
return msg, #msg + 25
elseif t == "number" then
-- Numbers are passed by value so need not be touched, and cost 8 bytes
-- as all numbers in Lua are doubles.
return msg, 8
elseif t == "boolean" then
-- Booleans are passed by value so need not be touched, and cost 1
-- byte.
return msg, 1
elseif t == "table" then
-- Tables are duplicated. Check if this table has been seen before
-- (self-referential or shared table); if so, reuse the cleaned value
-- of the previous occurrence, maintaining table topology and avoiding
-- infinite recursion, and charge zero bytes for this as the object has
-- already been counted.
back_references = back_references or {}
local bref = back_references[msg]
if bref then
return bref, 0
end
-- Construct a new table by cleaning all the keys and values and adding
-- up their costs, plus 8 bytes as a rough estimate of table overhead.
local cost = 8
local ret = {}
back_references[msg] = ret
for k, v in pairs(msg) do
local k_cost, v_cost
k, k_cost = clean_and_weigh_digiline_message(k, back_references)
v, v_cost = clean_and_weigh_digiline_message(v, back_references)
if k ~= nil and v ~= nil then
-- Only include an element if its key and value are of legal
-- types.
ret[k] = v
end
-- If we only counted the cost of a table element when we actually
-- used it, we would be vulnerable to the following attack:
-- 1. Construct a huge table (too large to pass the cost limit).
-- 2. Insert it somewhere in a table, with a function as a key.
-- 3. Insert it somewhere in another table, with a number as a key.
-- 4. The first occurrence doesnt pay the cost because functions
-- are stripped and therefore the element is dropped.
-- 5. The second occurrence doesnt pay the cost because its in
-- back_references.
-- By counting the costs regardless of whether the objects will be
-- included, we avoid this attack; it may overestimate the cost of
-- some messages, but only those that wont be delivered intact
-- anyway because they contain illegal object types.
cost = cost + k_cost + v_cost
end
return ret, cost
else
return nil, 0
end end
return interrupt
end end
local function get_digiline_send(pos) -- itbl: Flat table of functions to run after sandbox cleanup, used to prevent various security hazards
if not digiline then return end local function get_digiline_send(pos, itbl, send_warning)
if not minetest.global_exists("digilines") then return end
local chan_maxlen = mesecon.setting("luacontroller_digiline_channel_maxlen", 256)
local maxlen = mesecon.setting("luacontroller_digiline_maxlen", 50000)
return function(channel, msg) return function(channel, msg)
-- NOTE: This runs within string metatable sandbox, so don't *rely* on anything of the form (""):y
-- or via anything that could.
-- Make sure channel is string, number or boolean -- Make sure channel is string, number or boolean
if (type(channel) ~= "string" and type(channel) ~= "number" and type(channel) ~= "boolean") then if type(channel) == "string" then
if #channel > chan_maxlen then
send_warning("Channel string too long.")
return false
end
elseif (type(channel) ~= "string" and type(channel) ~= "number" and type(channel) ~= "boolean") then
send_warning("Channel must be string, number or boolean.")
return false return false
end end
-- It is technically possible to send functions over the wire since local msg_cost
-- the high performance impact of stripping those from the data has msg, msg_cost = clean_and_weigh_digiline_message(msg)
-- been decided to not be worth the added realism. if msg == nil or msg_cost > maxlen then
-- Make sure serialized version of the data is not insanely long to send_warning("Message was too complex, or contained invalid data.")
-- prevent DoS-like attacks
local msg_ser = minetest.serialize(msg)
if #msg_ser > mesecon.setting("luacontroller_digiline_maxlen", 50000) then
return false return false
end end
minetest.after(0, function() table.insert(itbl, function ()
digilines.receptor_send(pos, digiline_rules_luatube, channel, msg) -- Runs outside of string metatable sandbox
local luac_id = minetest.get_meta(pos):get_int("luac_id")
mesecon.queue:add_action(pos, "pipeworks:lt_digiline_relay", {channel, luac_id, msg})
end) end)
return true return true
end end
end end
local safe_globals = { local safe_globals = {
-- Don't add pcall/xpcall unless willing to deal with the consequences (unless very careful, incredibly likely to allow killing server indirectly)
"assert", "error", "ipairs", "next", "pairs", "select", "assert", "error", "ipairs", "next", "pairs", "select",
"tonumber", "tostring", "type", "unpack", "_VERSION" "tonumber", "tostring", "type", "unpack", "_VERSION"
} }
local function create_environment(pos, mem, event) local function create_environment(pos, mem, event, itbl, send_warning)
-- Make sure the tube hasn't broken. -- Make sure the tube hasn't broken.
local vports = minetest.registered_nodes[minetest.get_node(pos).name].virtual_portstates local vports = minetest.registered_nodes[minetest.get_node(pos).name].virtual_portstates
if not vports then return {} end if not vports then return {} end
@ -352,8 +484,8 @@ local function create_environment(pos, mem, event)
heat = mesecon.get_heat(pos), heat = mesecon.get_heat(pos),
heat_max = mesecon.setting("overheat_max", 20), heat_max = mesecon.setting("overheat_max", 20),
print = safe_print, print = safe_print,
interrupt = get_interrupt(pos), interrupt = get_interrupt(pos, itbl, send_warning),
digiline_send = get_digiline_send(pos), digiline_send = get_digiline_send(pos, itbl, send_warning),
string = { string = {
byte = string.byte, byte = string.byte,
char = string.char, char = string.char,
@ -441,10 +573,11 @@ local function create_sandbox(code, env)
jit.off(f, true) jit.off(f, true)
end end
local maxevents = mesecon.setting("luacontroller_maxevents", 10000)
return function(...) return function(...)
-- NOTE: This runs within string metatable sandbox, so the setting's been moved out for safety
-- Use instruction counter to stop execution -- Use instruction counter to stop execution
-- after luacontroller_maxevents -- after luacontroller_maxevents
local maxevents = mesecon.setting("luacontroller_maxevents", 10000)
debug.sethook(timeout, "", maxevents) debug.sethook(timeout, "", maxevents)
local ok, ret = pcall(f, ...) local ok, ret = pcall(f, ...)
debug.sethook() -- Clear hook debug.sethook() -- Clear hook
@ -455,7 +588,7 @@ end
local function load_memory(meta) local function load_memory(meta)
return minetest.deserialize(meta:get_string("lc_memory")) or {} return minetest.deserialize(meta:get_string("lc_memory"), true) or {}
end end
@ -465,6 +598,7 @@ local function save_memory(pos, meta, mem)
if (#memstring <= memsize_max) then if (#memstring <= memsize_max) then
meta:set_string("lc_memory", memstring) meta:set_string("lc_memory", memstring)
meta:mark_as_private("lc_memory")
else else
print("Error: lua_tube memory overflow. "..memsize_max.." bytes available, " print("Error: lua_tube memory overflow. "..memsize_max.." bytes available, "
..#memstring.." required. Controller overheats.") ..#memstring.." required. Controller overheats.")
@ -472,26 +606,42 @@ local function save_memory(pos, meta, mem)
end end
end end
-- Returns success (boolean), errmsg (string), retval(any, return value of the user supplied code)
local function run(pos, event) -- run (as opposed to run_inner) is responsible for setting up meta according to this output
local function run_inner(pos, code, event)
local meta = minetest.get_meta(pos) local meta = minetest.get_meta(pos)
if overheat(pos) then return end -- Note: These return success, presumably to avoid changing LC ID.
if ignore_event(event, meta) then return end if overheat(pos) then return true, "", nil end
if ignore_event(event, meta) then return true, "", nil end
-- Load code & mem from meta -- Load code & mem from meta
local mem = load_memory(meta) local mem = load_memory(meta)
local code = meta:get_string("code") local code = meta:get_string("code")
-- 'Last warning' label.
local warning = ""
local function send_warning(str)
warning = "Warning: " .. str
end
-- Create environment -- Create environment
local env = create_environment(pos, mem, event) local itbl = {}
local env = create_environment(pos, mem, event, itbl, send_warning)
-- Create the sandbox and execute code -- Create the sandbox and execute code
local f, msg = create_sandbox(code, env) local f, msg = create_sandbox(code, env)
if not f then return false, msg end if not f then return false, msg, nil end
local succ, msg = pcall(f) -- Start string true sandboxing
if not succ then return false, msg end local onetruestring = getmetatable("")
-- If a string sandbox is already up yet inconsistent, something is very wrong
assert(onetruestring.__index == string)
onetruestring.__index = env.string
local success, msg = pcall(f)
onetruestring.__index = string
-- End string true sandboxing
if not success then return false, msg, nil end
if type(env.port) ~= "table" then if type(env.port) ~= "table" then
return false, "Ports set are invalid." return false, "Ports set are invalid.", nil
end end
-- Actually set the ports -- Actually set the ports
@ -500,35 +650,81 @@ local function run(pos, event)
-- Save memory. This may burn the luacontroller if a memory overflow occurs. -- Save memory. This may burn the luacontroller if a memory overflow occurs.
save_memory(pos, meta, env.mem) save_memory(pos, meta, env.mem)
return succ, msg -- Execute deferred tasks
for _, v in ipairs(itbl) do
local failure = v()
if failure then
return false, failure, nil
end
end
return true, warning, msg
end end
mesecon.queue:add_function("pipeworks:lc_tube_interrupt", function (pos, luac_id, iid) local function reset_formspec(meta, code, errmsg)
-- There is no lua_tube anymore / it has been reprogrammed / replaced / burnt meta:set_string("code", code)
if (minetest.get_meta(pos):get_int("luac_id") ~= luac_id) then return end meta:mark_as_private("code")
if (minetest.registered_nodes[minetest.get_node(pos).name].is_burnt) then return end code = minetest.formspec_escape(code or "")
run(pos, {type = "interrupt", iid = iid}) errmsg = minetest.formspec_escape(tostring(errmsg or ""))
end) meta:set_string("formspec", "size[12,10]"
.."background[-0.2,-0.25;12.4,10.75;jeija_luac_background.png]"
.."label[0.1,8.3;"..errmsg.."]"
.."textarea[0.2,0.2;12.2,9.5;code;;"..code.."]"
.."image_button[4.75,8.75;2.5,1;jeija_luac_runbutton.png;program;]"
.."image_button_exit[11.72,-0.25;0.425,0.4;jeija_close_window.png;exit;]"
)
end
local function reset_meta(pos, code, errmsg) local function reset_meta(pos, code, errmsg)
local meta = minetest.get_meta(pos) local meta = minetest.get_meta(pos)
meta:set_string("code", code) reset_formspec(meta, code, errmsg)
code = minetest.formspec_escape(code or "")
errmsg = minetest.formspec_escape(tostring(errmsg or ""))
meta:set_string("formspec", "size[12,10]"..
"background[-0.2,-0.25;12.4,10.75;jeija_luac_background.png]"..
"textarea[0.2,0.2;12.2,9.5;code;;"..code.."]"..
"image_button[4.75,8.75;2.5,1;jeija_luac_runbutton.png;program;]"..
"image_button_exit[11.72,-0.25;0.425,0.4;jeija_close_window.png;exit;]"..
"label[0.1,9;"..errmsg.."]")
meta:set_int("luac_id", math.random(1, 65535)) meta:set_int("luac_id", math.random(1, 65535))
end end
-- Wraps run_inner with LC-reset-on-error
local function run(pos, event)
local meta = minetest.get_meta(pos)
local code = meta:get_string("code")
local ok, errmsg, retval = run_inner(pos, code, event)
if not ok then
reset_meta(pos, code, errmsg)
else
reset_formspec(meta, code, errmsg)
end
return ok, errmsg, retval
end
local function reset(pos) local function reset(pos)
set_port_states(pos, {red = false, blue = false, yellow = false, set_port_states(pos, {red = false, blue = false, yellow = false,
green = false, black = false, white = false}) green = false, black = false, white = false})
end end
local function node_timer(pos)
if minetest.registered_nodes[minetest.get_node(pos).name].is_burnt then
return false
end
run(pos, {type="interrupt"})
return false
end
-----------------------
-- A.Queue callbacks --
-----------------------
mesecon.queue:add_function("pipeworks:lc_tube_interrupt", function (pos, luac_id, iid)
-- There is no lua_tube anymore / it has been reprogrammed / replaced / burnt
if (minetest.get_meta(pos):get_int("luac_id") ~= luac_id) then return end
if (minetest.registered_nodes[minetest.get_node(pos).name].is_burnt) then return end
run(pos, {type="interrupt", iid = iid})
end)
mesecon.queue:add_function("pipeworks:lt_digiline_relay", function (pos, channel, luac_id, msg)
if not digiline then return end
-- This check is only really necessary because in case of server crash, old actions can be thrown into the future
if (minetest.get_meta(pos):get_int("luac_id") ~= luac_id) then return end
if (minetest.registered_nodes[minetest.get_node(pos).name].is_burnt) then return end
-- The actual work
digiline:receptor_send(pos, digiline_rules_luatube, channel, msg)
end)
----------------------- -----------------------
-- Node Registration -- -- Node Registration --
@ -558,6 +754,7 @@ local digiline = {
receptor = {}, receptor = {},
effector = { effector = {
action = function(pos, node, channel, msg) action = function(pos, node, channel, msg)
msg = clean_and_weigh_digiline_message(msg)
run(pos, {type = "digiline", channel = channel, msg = msg}) run(pos, {type = "digiline", channel = channel, msg = msg})
end end
}, },
@ -565,6 +762,18 @@ local digiline = {
rules = pipeworks.digilines_rules rules = pipeworks.digilines_rules
}, },
} }
local function get_program(pos)
local meta = minetest.get_meta(pos)
return meta:get_string("code")
end
local function set_program(pos, code)
reset(pos)
reset_meta(pos, code)
return run(pos, {type="program"})
end
local function on_receive_fields(pos, form_name, fields, sender) local function on_receive_fields(pos, form_name, fields, sender)
if not fields.program then if not fields.program then
return return
@ -574,12 +783,10 @@ local function on_receive_fields(pos, form_name, fields, sender)
minetest.record_protection_violation(pos, name) minetest.record_protection_violation(pos, name)
return return
end end
reset(pos) local ok, err = set_program(pos, fields.code)
reset_meta(pos, fields.code) if not ok then
local succ, err = run(pos, {type="program"}) -- it's not an error from the server perspective
if not succ then minetest.log("action", "Lua controller programming error: " .. tostring(err))
print(err)
reset_meta(pos, fields.code, err)
end end
end end
@ -715,7 +922,11 @@ for white = 0, 1 do
receptor = { receptor = {
state = mesecon.state.on, state = mesecon.state.on,
rules = output_rules[cid] rules = output_rules[cid]
} },
luacontroller = {
get_program = get_program,
set_program = set_program,
},
} }
minetest.register_node(node_name, { minetest.register_node(node_name, {
@ -723,6 +934,7 @@ for white = 0, 1 do
drawtype = "nodebox", drawtype = "nodebox",
tiles = tiles, tiles = tiles,
paramtype = "light", paramtype = "light",
is_ground_content = false,
groups = groups, groups = groups,
drop = BASENAME.."000000", drop = BASENAME.."000000",
sunlight_propagates = true, sunlight_propagates = true,
@ -749,6 +961,7 @@ for white = 0, 1 do
pipeworks.after_dig(pos, node) pipeworks.after_dig(pos, node)
end, end,
is_luacontroller = true, is_luacontroller = true,
on_timer = node_timer,
tubelike = 1, tubelike = 1,
tube = { tube = {
connect_sides = {front = 1, back = 1, left = 1, right = 1, top = 1, bottom = 1}, connect_sides = {front = 1, back = 1, left = 1, right = 1, top = 1, bottom = 1},
@ -762,7 +975,7 @@ for white = 0, 1 do
break break
end end
end end
local succ, msg = run(pos, { local succ, _, msg = run(pos, {
type = "item", type = "item",
pin = src, pin = src,
itemstring = stack:to_string(), itemstring = stack:to_string(),
@ -785,14 +998,6 @@ for white = 0, 1 do
minetest.swap_node(pos, {name = "pipeworks:broken_tube_1"}) minetest.swap_node(pos, {name = "pipeworks:broken_tube_1"})
pipeworks.scan_for_tube_objects(pos) pipeworks.scan_for_tube_objects(pos)
end, end,
on_blast = function(pos, intensity)
if not intensity or intensity > 1 + 3^0.5 then
minetest.remove_node(pos)
return {string.format("%s_%s", name, dropname)}
end
minetest.swap_node(pos, {name = "pipeworks:broken_tube_1"})
pipeworks.scan_for_tube_objects(pos)
end,
}) })
end end
end end
@ -836,6 +1041,7 @@ minetest.register_node(BASENAME .. "_burnt", {
tiles = tiles_burnt, tiles = tiles_burnt,
is_burnt = true, is_burnt = true,
paramtype = "light", paramtype = "light",
is_ground_content = false,
groups = {snappy = 3, tube = 1, tubedevice = 1, not_in_creative_inventory=1}, groups = {snappy = 3, tube = 1, tubedevice = 1, not_in_creative_inventory=1},
drop = BASENAME.."000000", drop = BASENAME.."000000",
sunlight_propagates = true, sunlight_propagates = true,