mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-16 00:02:23 +02:00
18d61779eb
We already ship the DLL, but mods can't ffi-load it under the sandbox, and the compat love.system shim wasn't passing tls* through either. So wss:// still died on stock builds. Engine loads the dialer at boot; compat forwards those keys; clipboard/openURL stay stubbed. Co-authored-by: Cursor <cursoragent@cursor.com>
393 lines
17 KiB
Lua
393 lines
17 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")
|
|
-- tls* is forwarded from the real love.system when the engine hung it
|
|
-- (Gen1Tls / Android JNI); without that it's just nil, not an error.
|
|
out.tlsOpenType = type(love.system.tlsOpen)
|
|
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")
|
|
T.check(out.tlsOpenType == "function" or out.tlsOpenType == "nil",
|
|
"tls* is readable through the system shim (nil until the engine hangs it)")
|
|
|
|
-- 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")
|