Files
gen1recomp/tests/modkit/cases/sandbox.lua
T
bryanthaboi 43cbc554c3 legcompat
2026-08-15 06:04:04 -04:00

388 lines
16 KiB
Lua

-- T4: the mod sandbox (src/mods/Sandbox.lua) and the compat reroute over it
-- (src/mods/LegacyCompat.lua). A mod's own chunks still cannot name a path
-- outside their own directory: the pre-sandbox globals are back as stand-ins
-- whose reads come from the mod's own files and whose writes land in a private
-- per-mod overlay. Every case here is an escape a mod would actually try.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Manifest = require("src.mods.Manifest")
local Sandbox = require("src.mods.Sandbox")
local SafePath = require("src.mods.SafePath")
local LegacyCompat = require("src.mods.LegacyCompat")
local function manifest(id, extra)
return ('{"id":"%s","name":"%s","version":"1.0.0","entry":"main.lua",'
.. '"api":2%s}'):format(id, id, extra or "")
end
-- what a probe reports back; pcall'd so one broken assumption does not take
-- the whole entry chunk down and hide the rest
local PROBE = [[
local mod = ...
local out = mod.exports
out.io = type(io)
out.package = type(package)
out.dofile = type(dofile)
out.loadfile = type(loadfile)
out.setfenv = setfenv
out.getfenv = getfenv
out.debug = debug
out.osGetenv = type(os.getenv)
out.osTime = type(os.time)
out.stringOk = ("a"):rep(3)
local function attempt(fn, ...)
local ok, err = pcall(fn, ...)
if ok then return false end
return tostring(err)
end
out.requireIoIsShim = select(2, pcall(require, "io")) == io
out.requireLoveFsIsShim =
select(2, pcall(require, "love.filesystem")) == love.filesystem
out.requireDebug = attempt(require, "debug")
out.requirePackage = attempt(require, "package")
out.requireFfi = attempt(require, "ffi")
out.requireSocket = attempt(require, "socket")
out.requireSemver = select(2, pcall(require, "src.mods.Semver"))
out.loveThread = attempt(function() return love.thread end)
out.loveGraphics = type(love.graphics)
out.loveAssign = attempt(function() love.filesystem = {} end)
-- the callback chain a mod wrapping the mouse writes: it has to reach the
-- real love table or the wrap silently never fires
out.chainedInner = false
local inner = love.mousemoved
love.mousemoved = function(...)
out.chainedInner = true
if inner then return inner(...) end
end
out.assignRun = attempt(function() love.run = function() end end)
out.assignGarbage = attempt(function() love.mousemoved = 7 end)
out.powerInfo = type(love.system.getPowerInfo)
out.openUrl = love.system.openURL("https://example.com")
out.eventQuit = love.event.quit()
out.popen = select(1, io.popen("ls"))
-- the reroute: a write anywhere a mod used to name must land in the mod's
-- own overlay, and reading it back must see the write and nothing else
local escape = io.open("/etc/hosts", "w")
out.escapeOpened = escape ~= nil
if escape then
escape:write("pwned")
escape:close()
end
local reread = io.open("/etc/hosts", "r")
out.escapeReadBack = reread and reread:read("*a") or nil
if reread then reread:close() end
out.homeEnv = os.getenv("HOME")
out.saveDir = love.filesystem.getSaveDirectory()
love.filesystem.write("cfg/settings.txt", "x=1\ny=2\n")
out.roundTrip = love.filesystem.read("cfg/settings.txt")
out.roundTripInfo = love.filesystem.getInfo("cfg/settings.txt")
local lines = {}
for line in love.filesystem.lines("cfg/settings.txt") do
lines[#lines + 1] = line
end
out.roundTripLines = lines
love.filesystem.append("cfg/settings.txt", "z=3\n")
out.appended = love.filesystem.read("cfg/settings.txt")
-- an absolute path built off the reported save directory comes back to the
-- same overlay, which is what a legacy mod's own path joining does
love.filesystem.write(out.saveDir .. "/cfg/settings.txt", "rooted")
out.rootedRead = love.filesystem.read("cfg/settings.txt")
-- the mod's own packaged files still read through the old call
out.ownThroughLove = love.filesystem.read("mods/fix_sandbox/data/note.txt")
out.ownThroughIo = (function()
local f = io.open("data/note.txt", "r")
if not f then return nil end
local body = f:read("*a")
f:close()
return body
end)()
-- copy-on-write: writing over a packaged path shadows it, it does not
-- rewrite the shipped file
love.filesystem.write("mods/fix_sandbox/data/note.txt", "shadowed")
out.shadowed = love.filesystem.read("mods/fix_sandbox/data/note.txt")
out.shadowedOwn = mod:read("data/note.txt")
-- the multi-file pattern mods/timekeepers_hut uses: a chunk loaded from the
-- mod's own source must inherit the sandbox, not the real globals
local child = load("return io, os.getenv, _G")
local childIo, childGetenv, childG = child()
out.childIoIsShim = childIo == io
out.childGetenvIsShim = childGetenv == os.getenv
out.childSharesEnv = childG == _G
out.readEscape = attempt(function() return mod:read("../../secret.txt") end)
out.readAbsolute = attempt(function() return mod:read("/etc/hosts") end)
out.readBackslash = attempt(function() return mod:read("..\\secret.txt") end)
out.assetsEscape = attempt(function() return mod.assets:path("../../x.png") end)
out.readOwn = mod:read("data/note.txt")
out.listAssets = mod:list("assets")
out.listSprites = mod:list("assets/sprites")
out.listRoot = mod:list()
out.assetsList = mod.assets:list("assets")
out.infoAssets = mod:info("assets")
out.infoNote = mod:info("data/note.txt")
out.infoMissing = mod:info("nope")
out.listMissing = mod:list("nope")
out.listEscape = attempt(function() return mod:list("../secret") end)
out.infoEscape = attempt(function() return mod:info("../../x") end)
_G.SANDBOX_LEAK = "escaped"
out.globalsAreOwn = _G ~= nil and _G.SANDBOX_LEAK == "escaped"
-- a mod stomping the standard library must not reach the engine
table.insert = function() error("stomped") end
string.format = function() error("stomped") end
]]
local FILES = {
["mods/fix_sandbox/manifest.json"] = manifest("fix_sandbox"),
["mods/fix_sandbox/main.lua"] = PROBE,
["mods/fix_sandbox/data/note.txt"] = "own file",
["mods/fix_sandbox/assets/front.png"] = "png",
["mods/fix_sandbox/assets/sprites/walk.png"] = "png",
}
LegacyCompat.reset()
local savedMouseMoved = love.mousemoved
local run = T.sdk.loadMods({ "mods/fix_sandbox" }, { fs = T.sdk.memfs(FILES) })
local installedMouseMoved = love.mousemoved
love.mousemoved = savedMouseMoved
T.eq(#run.errors, 0,
"the probe mod loads clean (" .. tostring(run.errors[1]) .. ")")
local out = run.loader.exports.fix_sandbox or {}
-- ------- the pre-sandbox globals are stand-ins, not the real thing
T.eq(out.io, "table", "io is present again, as the compat stand-in")
T.eq(out.package, "table", "so is package, as an inert stub")
T.eq(out.dofile, "function", "dofile routes through the stand-in")
T.eq(out.loadfile, "function", "so does loadfile")
T.eq(out.osGetenv, "function", "os.getenv answers rather than crashing the mod")
T.eq(out.osTime, "function", "os.time still works: the clock was never the hole")
T.eq(out.stringOk, "aaa", "the safe standard library is intact")
-- what stays gone: there is no rerouted stand-in for these, so faking one
-- would be the hole rather than a compat shim
T.eq(out.setfenv, nil, "setfenv is still absent, so a mod cannot swap its own env")
T.eq(out.getfenv, nil, "getfenv is still absent, so a mod cannot read the real _G out")
T.eq(out.debug, nil, "the debug library is still absent")
T.check(out.loveThread ~= false, "love.thread is still refused: it opens a full Lua state")
T.check(out.requireFfi ~= false, "require(\"ffi\") is still refused: it is arbitrary C")
T.check(out.requireDebug ~= false, "require(\"debug\") is still refused")
T.check(out.requirePackage ~= false, "require(\"package\") is still refused")
T.eq(out.popen, nil, "io.popen refuses rather than spawning a process")
T.eq(out.openUrl, false, "love.system.openURL does nothing")
T.eq(out.eventQuit, false, "love.event.quit cannot close the game on the player")
-- ------- require answers with the same stand-ins
T.check(out.requireIoIsShim, "require(\"io\") hands back the same compat table")
T.check(out.requireLoveFsIsShim,
"require(\"love.filesystem\") hands back the same compat table")
T.check(out.requireSocket and out.requireSocket:find("network", 1, true),
"a network module still names the permission it needs: " .. tostring(out.requireSocket))
T.eq(type(out.requireSemver), "table",
"the supported engine requires still resolve")
-- ------- the love facade
T.eq(out.loveGraphics, "table", "the rest of love passes through")
T.check(out.loveAssign ~= false,
"a mod cannot replace a love module table: " .. tostring(out.loveAssign))
T.eq(out.powerInfo, "function",
"love.system reads through to the same information mod.device exposes")
-- a wrapped callback has to land on the real table or the wrap never fires
T.eq(type(installedMouseMoved), "function",
"love.mousemoved assigned by a mod reaches the real love table")
T.check(installedMouseMoved ~= savedMouseMoved,
"and it is the mod's wrapper, not the one that was there")
T.check(out.assignRun and out.assignRun:find("fixed-step loop", 1, true),
"love.run stays refused: it is the engine's own loop ("
.. tostring(out.assignRun) .. ")")
T.check(out.assignGarbage ~= false,
"and a callback slot only takes a function")
-- ------- containment: every rerouted write lands in the mod's own overlay
T.check(out.escapeOpened, "io.open on an absolute path outside the tree opens")
T.eq(out.escapeReadBack, "pwned", "and reads its own write back")
T.eq(FILES["/etc/hosts"], nil,
"but nothing was written outside the game tree")
T.eq(FILES["mod_compat/fix_sandbox/etc/hosts"], "pwned",
"the bytes went to this mod's private overlay instead")
T.eq(out.homeEnv, "/pokeport/fix_sandbox",
"os.getenv(\"HOME\") answers with the mod's virtual root, not the real one")
T.eq(out.saveDir, "/pokeport/fix_sandbox",
"and so does the reported save directory")
T.eq(out.roundTrip, "x=1\ny=2\n", "a love.filesystem write reads back")
T.eq(out.roundTripInfo and out.roundTripInfo.type, "file",
"and getInfo sees it")
T.same(out.roundTripLines, { "x=1", "y=2" }, "lines() walks it")
T.eq(out.appended, "x=1\ny=2\nz=3\n", "append extends it")
T.eq(out.rootedRead, "rooted",
"a path joined to the reported save directory routes to the same key")
T.eq(FILES["mod_compat/fix_sandbox/cfg/settings.txt"], "rooted",
"one key in the overlay, under the mod's own id, however it was named")
-- ------- reads still see the mod's packaged files
T.eq(out.ownThroughLove, "own file",
"love.filesystem.read of a path inside the mod reads the shipped file")
T.eq(out.ownThroughIo, "own file", "and so does io.open on a relative path")
T.eq(out.shadowed, "shadowed", "a write over a packaged path shadows it")
T.eq(FILES["mods/fix_sandbox/data/note.txt"], "own file",
"without rewriting what the mod shipped")
T.eq(out.shadowedOwn, "own file",
"and mod:read still reports the packaged bytes")
-- ------- the reroute is reported, not silent
do
local report = run.loader:legacyReport("fix_sandbox")
local calls = {}
for _, row in ipairs(report) do calls[row.call] = row end
T.check(calls["io.open"], "io.open is recorded against the mod")
T.check(calls["love.filesystem.write"], "so is love.filesystem.write")
T.check(calls["os.getenv"], "and os.getenv")
T.check(calls["love.filesystem.write"].count >= 2,
"with a count, so a manager can rank the worst offenders")
T.check(calls["io.open"].advice and #calls["io.open"].advice > 0,
"each row carries the advice the warning printed")
end
-- ------- env propagation and isolation
T.check(out.childIoIsShim,
"a chunk a mod load()s inherits the sandbox (5.1 would hand it the real _G)")
T.check(out.childGetenvIsShim, "the child chunk gets the same rerouted os")
T.check(out.childSharesEnv, "the child chunk shares the mod's own globals table")
T.check(out.globalsAreOwn, "a mod's globals write to its own table")
T.eq(_G.SANDBOX_LEAK, nil, "and never reach the engine's _G")
T.eq(("%d"):format(1), "1",
"a mod stomping string.format cannot reach the engine's copy")
do
local probe = {}
table.insert(probe, "still works")
T.eq(probe[1], "still works",
"nor table.insert -- each mod gets its own standard-library namespace")
end
-- ------- paths
T.check(out.readEscape and out.readEscape:find("must stay inside", 1, true),
"mod:read cannot climb out of the mod directory: " .. tostring(out.readEscape))
T.check(out.readAbsolute ~= false, "mod:read refuses an absolute path")
T.check(out.readBackslash ~= false, "mod:read refuses a backslash climb")
T.check(out.assetsEscape ~= false, "mod.assets:path refuses a climb")
T.eq(out.readOwn, "own file", "and the mod's own files still read")
T.same(out.listAssets, { "front.png", "sprites" },
"mod:list names the children of a directory inside the mod")
T.same(out.listSprites, { "walk.png" },
"and a nested directory")
T.check(out.listRoot and out.listRoot[1] ~= nil,
"mod:list() with no path lists the mod root")
T.same(out.assetsList, out.listAssets,
"mod.assets:list is the same listing")
T.eq(out.infoAssets and out.infoAssets.type, "directory",
"mod:info reports a directory")
T.eq(out.infoNote and out.infoNote.type, "file",
"and a file")
T.eq(out.infoMissing, nil, "mod:info is nil for a missing path")
T.same(out.listMissing, {}, "mod:list of a missing path is empty, not an error")
T.check(out.listEscape and out.listEscape:find("must stay inside", 1, true),
"mod:list cannot climb out of the mod directory: " .. tostring(out.listEscape))
T.check(out.infoEscape and out.infoEscape:find("must stay inside", 1, true),
"mod:info cannot climb either")
run.release()
-- ------- two mods never share an overlay
do
local files = {
["mods/one/manifest.json"] = manifest("one"),
["mods/one/main.lua"] = [[
local mod = ...
love.filesystem.write("shared.txt", "from one")
mod.exports.mine = love.filesystem.read("shared.txt")
]],
["mods/two/manifest.json"] = manifest("two"),
["mods/two/main.lua"] = [[
local mod = ...
mod.exports.peek = love.filesystem.read("shared.txt")
mod.exports.climb = love.filesystem.read("../one/shared.txt")
]],
}
local pair = T.sdk.loadMods({ "mods/one", "mods/two" },
{ fs = T.sdk.memfs(files) })
T.eq(#pair.errors, 0, "both mods load (" .. tostring(pair.errors[1]) .. ")")
T.eq(pair.loader.exports.one.mine, "from one", "the first mod sees its write")
T.eq(pair.loader.exports.two.peek, nil,
"the second mod, naming the same path, sees nothing")
T.eq(files["mod_compat/one/shared.txt"], "from one",
"because the overlay is keyed by mod id")
T.eq(pair.loader.exports.two.climb, nil,
"and a climb out of the overlay resolves inside it, not into the neighbour")
pair.release()
end
-- ------- the grammar itself
for _, bad in ipairs({ "../x", "a/../../x", "/etc/hosts", "C:/Windows/x",
"..\\x", "a\\b", "..", ".", "" }) do
T.eq(SafePath.safe(bad), nil, ("SafePath rejects %q"):format(bad))
end
T.eq(SafePath.safe("maps/NEW_BARK_TOWN.lua"), "maps/NEW_BARK_TOWN.lua",
"an ordinary relative path passes")
T.eq(SafePath.safe("./main.lua"), "main.lua",
"a leading ./ is normalized rather than rejected, so older manifests load")
-- ------- manifest paths are untrusted input too
T.check(not pcall(Manifest.validate,
{ id = "evil", name = "evil", version = "1.0.0", entry = "../../../evil.lua" }),
"a manifest cannot point entry outside the mod directory")
T.check(not pcall(Manifest.validate,
{ id = "evil", name = "evil", version = "1.0.0", entry = "main.lua",
options_schema = "../../options.lua" }),
"nor options_schema")
T.check(pcall(Manifest.validate,
{ id = "fine", name = "fine", version = "1.0.0", entry = "main.lua" }),
"an ordinary manifest still validates")
-- ------- bytecode
do
local bad = {
["mods/fix_bytecode/manifest.json"] = manifest("fix_bytecode"),
["mods/fix_bytecode/main.lua"] = string.dump(function() end),
}
local bytecodeRun = T.sdk.loadMods({ "mods/fix_bytecode" },
{ fs = T.sdk.memfs(bad) })
T.eq(#bytecodeRun.errors, 1, "a mod that ships bytecode fails to load")
T.check(tostring(bytecodeRun.errors[1]):find("bytecode", 1, true),
"and says why: " .. tostring(bytecodeRun.errors[1]))
bytecodeRun.release()
end
-- ------- the sandbox with no compat layer is still closed
do
local env = Sandbox.envFor({ modId = "probe" })
T.eq(env.io, nil, "a bare Sandbox.envFor has no io")
T.eq(env._G, env, "_G points at the sandbox, not the real globals")
T.check(not pcall(env.require, "io"), "and its require refuses io")
end
T.finish("sandbox")