Recursive Read Only Tables |
|
Lua Version: 5.x
Prerequisites: Familiarity with metamethods (see MetamethodsTutorial)
In the spirit of ReadOnlyTables, I needed a way to provide access control in a multi-user Lua system. Notably, it is a requirement that users have read-only access to complex data structures, and should not be able to modify them in any way.
It is a goal that even savvy Lua users should not be able to circumvent the protection.
The (as yet mostly untested) solution I've come up with follows:
-- cache the metatables of all existing read-only tables, -- so our functions can get to them, but user code can't local metatable_cache = setmetatable({}, {__mode='k'}) local function make_getter(real_table) local function getter(dummy, key) local ans=real_table[key] if type(ans)=='table' and not metatable_cache[ans] then ans = make_read_only(ans) end return ans end return getter end local function setter(dummy) error("attempt to modify read-only table", 2) end local function make_pairs(real_table) local function pairs() local key, value, real_key = nil, nil, nil local function nexter() -- both args dummy key, value = next(real_table, real_key) real_key = key if type(key)=='table' and not metatable_cache[key] then key = make_read_only(key) end if type(value)=='table' and not metatable_cache[value] then value = make_read_only(value) end return key, value end return nexter -- values 2 and 3 dummy end return pairs end function make_read_only(t) local new={} local mt={ __metatable = "read only table", __index = make_getter(t), __newindex = setter, __pairs = make_pairs(t), __type = "read-only table"} setmetatable(new, mt) metatable_cache[new]=mt return new end function ropairs(t) local mt = metatable_cache[t] if mt==nil then error("bad argument #1 to 'ropairs' (read-only table expected, got " .. type(t) .. ")", 2) end return mt.__pairs() end
__type
and __pairs
are set in each read-only table's metatable to support the cooresponding extensions to the standard library. Other than the metamethods, this module only exports ropairs
, a version of pairs
for read-only tables (which uses __pairs
), and make_read_only
, a constructor of read-only tables.
I would prefer to cache the read-only version of each table so as to avoid making redundant copies (and support equality testing of read-only tables) but as RiciLake points out at GarbageCollectingWeakTables, cacheing recursive data is problematic in Lua. Happily, read-only tables are rather lightweight, so this isn't as big as a problem as it could be.
In my implementation, I will probably modify the line local new={}
in make_read_only
to generate a new userdata instead, to properly catch attempts to treat a read-only table like a standard table (using, for instance, pairs
or ipairs
or rawset
or table.insert
.
I'm posting this early in hopes of getting some feedback. I need a solution to this problem, and this turned out to be easier to code in pure Lua than I had hoped. But any improvements would be welcomed.
A sample usage follows.
Protection seems to work fine:
> tab = { one=1, two=2, sub={} } > tab.sub[{}]={} > rotab=make_read_only(tab) > =rotab.two 2 > =rotab.three nil > rotab.two='two' stdin:1: attempt to modify read-only table stack traceback: ... > rotab.sub.foo='bar' stdin:1: attempt to modify read-only table stack traceback: ...
Unfortunately, each access of a subtable returns a freshly-created read-only table. If a table is a key in a read-only table, you can't pull it out of the read-only table, but you can still use it as a key if you have access to it by other means.
> key={'Lua!'} > rot=make_read_only {[key]=12345} > for k,v in ropairs(rot) do print (k,v) end table: 003DD990 12345 > for k,v in ropairs(rot) do print (k,v) end table: 00631568 12345 > =rot[key] 12345 > for k,_ in ropairs(rot) do k[2]='Woot!' end stdin:1: attempt to modify read-only table stack traceback: ...
I want to strengthen this to honor the wrapped table's __index and __pairs metatables, and hopefully come up with a caching strategy that doesn't break garbage collection. Maybe I'll post RecursiveReadOnlyTablesTwo? one day.
-- VeLoSo
I saw your implementation and thought I would see if I could implement something that got around a few of the catches you were experiencing. Some of the catches in your case break the code in my case. One such case is that each subtable access returns a freshly-created read-only table. I also was afraid of breaking garbage collection since the objects that I want to make read-only are created and destroyed extremely often. I do not have the experience that I would like to working with metatables and these kinds of constructs so any advice or corrections would be much appreciated.
-- recursive read-only definition function readOnly(t) for x, y in pairs(t) do if type(x) == "table" then if type(y) == "table" then t[readOnly(x)] = readOnly[y] else t[readOnly(x)] = y end elseif type(y) == "table" then t[x] = readOnly(y) end end local proxy = {} local mt = { -- hide the actual table being accessed __metatable = "read only table", __index = function(tab, k) return t[k] end, __pairs = function() return pairs(t) end, __newindex = function (t,k,v) error("attempt to update a read-only table", 2) end } setmetatable(proxy, mt) return proxy end local oldpairs = pairs function pairs(t) local mt = getmetatable(t) if mt==nil then return oldpairs(t) elseif type(mt.__pairs) ~= "function" then return oldpairs(t) end return mt.__pairs() end
> local test = {"a", "b", c = 12, {x = 1, y = 2}} > test = readOnly(test) > for k, v in pairs(test) do > print(k, v) > end 1 a 2 b 3 table: 0x806f34a0 c 12 > =test[1] a > -- anyone know how to break this one? The code above by VeLoSo also lets this through > table.insert(test, "blah") > =test[1] blah > test.new = 3 stdin:1: attempt to modify read-only table stack traceback: ... > test[3] = "something" stdin:1: attempt to modify read-only table stack traceback: ... > test[3].new = "something" stdin:1: attempt to modify read-only table stack traceback: ...
I am not sure how safe this is from an expert who is given a read-only table, but it seems that since the table is only ever referenced indirectly through a closure, it should be completely safe from damage. Also, as far as I can tell, this will not break garbage collection. Let me know what you think.
-- ZachDwiel?