diff --git a/CONTRIBUTING-mods.md b/CONTRIBUTING-mods.md index 295934f9..c2a8f462 100644 --- a/CONTRIBUTING-mods.md +++ b/CONTRIBUTING-mods.md @@ -246,17 +246,37 @@ real Gold boot. Your code runs in a sandbox (`src/mods/Sandbox.lua`), not against the engine's globals. Every chunk you author gets it: `main.lua`, your -`options_schema`, and anything you `load()` yourself. What is absent: +`options_schema`, and anything you `load()` yourself. -| Absent | Use instead | +The globals the sandbox took away are still *reachable*, as compat +stand-ins (`src/mods/LegacyCompat.lua`) that answer with the new API +underneath. A mod written before the sandbox keeps working; it logs one +warning per call it should migrate, and the mod manager lists them. What +each stand-in actually does: + +| Pre-sandbox call | What it does now | Migrate to | +| --- | --- | --- | +| `io.open`, `io.lines`, `love.filesystem.read`/`lines`/`newFile` | reads your own shipped files, then your overlay, then `mod.storage` | `mod:read`, `mod.storage` | +| `love.filesystem.write`/`append`, `io.open(…, "w")`, `os.remove`, `os.rename` | writes to a private per-mod overlay under `mod_compat//` | `mod.storage` | +| `love.filesystem.getDirectoryItems`/`getInfo` | your own directory plus your overlay | `mod:list`, `mod:info` | +| `love.filesystem.getSaveDirectory` and friends | a virtual root; anything joined to it lands in your overlay | `mod.storage` | +| `os.getenv` | `nil`, except home-like names, which answer with that same virtual root | nothing | +| `love.filesystem.load`, `dofile`, `loadfile` | compiles the chunk into your sandbox | `require`, `mod:read` plus `load` | +| `love.system` | `getOS`/`getPowerInfo`/`getProcessorCount` read through; clipboard and `openURL` do nothing | `mod.device:powerInfo()`, `mod.steps` | +| `love.event` | passes through, except `quit`, which does nothing | `mod.events`, `mod.hooks` | +| `love.mousemoved = fn` and the other callbacks | installs on the real `love` table, the way it always did | `mod.hooks`, `mod.events` | +| `package` | an inert stub, so `package.path = …` does not crash | `require` | + +What has no stand-in, because there is nothing honest to reroute it to: + +| Still refused | Why | | --- | --- | -| `io`, and `require("io")` | `mod:read` for your own files, `mod.storage` to persist | -| `os.getenv`, `os.execute`, `os.remove`, `os.rename`, `os.exit` | nothing; `os.time`/`os.date`/`os.clock` still work | -| `package`, `dofile`, `loadfile`, `debug`, `getfenv`, `setfenv` | `require` for the supported engine modules | -| `require("ffi")`, `require("love.*")` | the `love` table you are given | -| `love.filesystem` | `mod.storage` (per-mod, per-playthrough), `mod:read` for a known file, `mod:list` / `mod:info` to iterate your own directory | -| `love.thread`, `love.event` | `mod.events`, `mod.hooks` | -| `love.system` | `mod.device:powerInfo()` for battery information; `mod.steps` (with the `steps` permission) for the step bridge | +| `love.thread` | a LÖVE thread is a fresh Lua state with the full standard library, which no environment-based sandbox in this state can reach | +| `require("ffi")` | arbitrary C | +| `debug`, `getfenv`, `setfenv` | each one undoes the sandbox from inside | +| `io.popen`, `os.execute` | spawning a process | +| `love.run`, `love.errorhandler` | the engine's own loop and its crash path | +| replacing a `love` module table (`love.filesystem = {}`) | the engine reads those tables too | The rest of `love` passes through unchanged, so graphics, audio, timers and input work as they always have. @@ -283,8 +303,12 @@ permission that grants raw filesystem access, because no mod needs one: everything a mod legitimately writes is already scoped by `mod.storage` or the asset-transform derived root. -If your mod used one of the absent globals, the fix is almost always -`mod.storage`. Open an issue if you have a case it does not cover. +If your mod used one of the rerouted globals, the fix is almost always +`mod.storage`. The overlay is a compatibility floor, not a second storage +system: it is not scoped per playthrough, it does not migrate, and it is +the first thing that will be dropped once the mods on the index have +moved off it. Open an issue if you have a case `mod.storage` does not +cover. ### 6. `mod.card` diff --git a/docs/modding.md b/docs/modding.md index 36f997c9..2335bc81 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -802,3 +802,37 @@ the native side's pending file itself, each permissioned mod receives its own copy of a delivery, and steps are anchored natively so the same walk is never delivered twice. Without the permission, `sync` and `poll` raise an error naming it. + +## Pre-sandbox globals (compat) + +A mod written before the sandbox landed does not have to be updated to +load. `io`, `package`, `dofile`, `loadfile`, `os.getenv`, `love.filesystem`, +`love.system` and `love.event` are all present again as compat stand-ins +(`src/mods/LegacyCompat.lua`), and assigning a LÖVE callback +(`love.mousemoved = fn`) installs on the real table the way it always did. +Every stand-in call logs one warning naming its replacement, and +`loader:legacyReport(modId)` returns the same list with call counts, which +is what a "needs updating" badge should read. + +The stand-ins are not the old globals. Paths are classified rather than +passed through: + +- A path inside your own mod directory reads the file you shipped. +- Anything else, including an absolute path, resolves into a private + per-mod overlay at `mod_compat//` under the save directory. + Two mods naming the same path never see each other's bytes, and nothing + is written outside the game tree. +- A read misses through the overlay to your shipped file, then to + `mod.storage`, so a half-migrated mod sees both. +- A write over a path you shipped shadows it; the packaged file is never + modified, and `mod:read` still returns the packaged bytes. +- `love.filesystem.getSaveDirectory()` and `os.getenv("HOME")` answer with + a virtual root, so a legacy mod that joins its own paths lands back in + the same overlay. + +`love.thread` stays refused. A LÖVE thread runs in a separate Lua state +with the full standard library, which the sandbox in this state cannot +reach, so a stand-in would be a hole rather than a reroute. The same goes +for `ffi`, `debug`, `setfenv`, `os.execute`, `io.popen`, `love.run` and +`love.errorhandler`. A mod that needs real background work needs an +engine-owned facility, not a compat shim. diff --git a/docs/new-features.md b/docs/new-features.md index 80c47172..c59ccd44 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -40,3 +40,4 @@ A fourth game the launcher can import and play, built from pret/pokegold the sam * **On-screen touch pad** and controller SELECT for registered items +* **Older mods keep loading** after the sandbox change, through per-mod compat stand-ins for the pre-sandbox globals diff --git a/src/mods/LegacyCompat.lua b/src/mods/LegacyCompat.lua new file mode 100644 index 00000000..0f8a1cf2 --- /dev/null +++ b/src/mods/LegacyCompat.lua @@ -0,0 +1,943 @@ +local Logger = require("src.core.Logger") +local SafePath = require("src.mods.SafePath") + +local okSave, SaveData = pcall(require, "src.core.SaveData") +if not okSave then SaveData = nil end +local okStorage, Storage = pcall(require, "src.mods.Storage") +if not okStorage then Storage = nil end + +local LegacyCompat = {} + +local ROOT = "mod_compat" +local OWN = "_own" +local MAX_SEGMENTS = 24 + +LegacyCompat.reports = {} + +function LegacyCompat.reset() + LegacyCompat.reports = {} +end + +function LegacyCompat.report(modId) + if modId then + local entry = LegacyCompat.reports[modId] + return entry and entry.order or {} + end + local out = {} + for id, entry in pairs(LegacyCompat.reports) do + out[id] = entry.order + end + return out +end + +local function note(ctx, call, advice) + local entry = LegacyCompat.reports[ctx.modId] + if not entry then + entry = { calls = {}, order = {} } + LegacyCompat.reports[ctx.modId] = entry + end + local row = entry.calls[call] + if row then + row.count = row.count + 1 + return + end + row = { call = call, advice = advice, count = 1 } + entry.calls[call] = row + entry.order[#entry.order + 1] = row + Logger.warn("[%s] %s was removed from the mod sandbox; %s", ctx.modId, call, + advice) +end + +local function refuse(ctx, call, advice) + note(ctx, call, advice) + return nil, ("%s is not available to mods; %s"):format(call, advice) +end + +-- ------- path routing + +local function normalize(path) + if type(path) ~= "string" or path == "" then return nil end + path = path:gsub("\\", "/") + while path:find("//", 1, true) do path = (path:gsub("//", "/")) end + if path ~= "/" then path = (path:gsub("/$", "")) end + return path +end + +local function flatten(path) + local parts = {} + for segment in path:gmatch("[^/]+") do + if segment ~= "." and segment ~= ".." then + parts[#parts + 1] = (segment:gsub("[^%w_%.%-]", "_")) + end + end + if #parts == 0 then return nil end + while #parts > MAX_SEGMENTS do table.remove(parts, 1) end + return table.concat(parts, "/") +end + +local function storageKey(key) + local parts = {} + for segment in key:gmatch("[^/]+") do parts[#parts + 1] = segment end + if #parts == 0 then return nil end + parts[#parts] = (parts[#parts]:gsub("%.[^%.]*$", "")) + for i = 1, #parts do + local cleaned = (parts[i]:gsub("[^%w_%-]", "_")) + if cleaned == "" then return nil end + parts[i] = cleaned + end + return table.concat(parts, "/") +end + +local function ownRelative(ctx, path) + if not ctx.modPath then return nil end + if path == ctx.modPath then return "" end + if path:sub(1, #ctx.modPath + 1) == ctx.modPath .. "/" then + return path:sub(#ctx.modPath + 2) + end + return nil +end + +local function ownFull(ctx, rel) + if rel == "" then return ctx.modPath end + local safe = SafePath.safe(rel) + if not safe then return nil end + return ctx.modPath .. "/" .. safe +end + +local function ownExists(ctx, rel) + local full = ownFull(ctx, rel) + local fs = ctx.fs + if not (full and fs and fs.getInfo) then return nil end + return fs.getInfo(full) +end + +local function classify(ctx, path) + path = normalize(path) + if not path then return nil end + + local root = ctx.virtualRoot + if path == root then return { key = "", dir = true } end + if path:sub(1, #root + 1) == root .. "/" then + return { key = flatten(path:sub(#root + 2)) } + end + + local rel = ownRelative(ctx, path) + if not rel and path:sub(1, 1) ~= "/" and not path:match("^%a:") + and ownExists(ctx, path) then + rel = path + end + if rel then + if rel == "" then return { key = OWN, rel = "", dir = true } end + local flat = flatten(rel) + return { key = flat and (OWN .. "/" .. flat) or nil, rel = rel } + end + + return { key = flatten(path) } +end + +-- ------- the overlay + +local function persistFs(ctx) + if SaveData and SaveData.persistenceFs then + return SaveData.persistenceFs(ctx.fs) + end + return ctx.fs +end + +local function overlayPath(ctx, key) + if key == nil or key == "" then return ROOT .. "/" .. ctx.modId end + return ROOT .. "/" .. ctx.modId .. "/" .. key +end + +local function ensureParent(fs, path) + if not fs.createDirectory then return end + local dir = path:match("^(.*)/[^/]+$") + if not dir then return end + local built = nil + for segment in dir:gmatch("[^/]+") do + built = built and (built .. "/" .. segment) or segment + fs.createDirectory(built) + end +end + +local function overlayRead(ctx, key) + local fs = persistFs(ctx) + if not (fs and fs.read and fs.getInfo) then return nil end + local path = overlayPath(ctx, key) + local info = fs.getInfo(path) + if not info or info.type == "directory" then return nil end + local body = fs.read(path) + if type(body) ~= "string" then return nil end + return body +end + +local function overlayWrite(ctx, key, data) + local fs = persistFs(ctx) + if not (fs and fs.write) then + return false, "no writable filesystem is available" + end + local path = overlayPath(ctx, key) + ensureParent(fs, path) + local ok, err = fs.write(path, data) + if ok == false then return false, err or "write failed" end + return true +end + +local function overlayRemove(ctx, key) + local fs = persistFs(ctx) + if not (fs and fs.remove and fs.getInfo) then return false end + local path = overlayPath(ctx, key) + if not fs.getInfo(path) then return false end + fs.remove(path) + return true +end + +local function overlayInfo(ctx, key) + local fs = persistFs(ctx) + if not (fs and fs.getInfo) then return nil end + return fs.getInfo(overlayPath(ctx, key)) +end + +local function storageRead(ctx, key) + if not (Storage and key and key ~= "") then return nil end + if key:sub(1, #OWN + 1) == OWN .. "/" then return nil end + local game = ctx.game and ctx.game() + if not game then return nil end + local sk = storageKey(key) + if not sk then return nil end + ctx.storage = ctx.storage or Storage.new(ctx.modId, ctx.fs) + local ok, bytes = pcall(ctx.storage.readBytes, ctx.storage, game, sk) + if ok and type(bytes) == "string" then return bytes end + return nil +end + +local function readPath(ctx, path) + local at = classify(ctx, path) + if not at then return nil, "invalid path" end + local body = at.key and overlayRead(ctx, at.key) + if body then return body end + if at.rel then + local full = ownFull(ctx, at.rel) + local fs = ctx.fs + if full and fs and fs.read then + local packaged = fs.read(full) + if type(packaged) == "string" then return packaged end + end + end + body = at.key and storageRead(ctx, at.key) + if body then return body end + return nil, "could not open " .. tostring(path) +end + +local function writePath(ctx, path, data) + local at = classify(ctx, path) + if not (at and at.key) then return false, "invalid path" end + return overlayWrite(ctx, at.key, data) +end + +local function infoPath(ctx, path) + local at = classify(ctx, path) + if not at then return nil end + if at.key then + local info = overlayInfo(ctx, at.key) + if info then + return { type = info.type, size = info.size, modtime = info.modtime } + end + end + if at.rel then + local info = ownExists(ctx, at.rel) + if info then + return { type = info.type, size = info.size, modtime = info.modtime } + end + end + local stored = at.key and storageRead(ctx, at.key) + if stored then return { type = "file", size = #stored } end + return nil +end + +local function listPath(ctx, path) + local at = classify(ctx, path) + local seen, out = {}, {} + local function add(name) + if name and name ~= "" and not seen[name] then + seen[name] = true + out[#out + 1] = name + end + end + if at and at.rel then + local full = ownFull(ctx, at.rel) + local fs = ctx.fs + if full and fs and fs.getDirectoryItems then + for _, name in ipairs(fs.getDirectoryItems(full) or {}) do add(name) end + end + end + if at and at.key then + local fs = persistFs(ctx) + if fs and fs.getDirectoryItems then + for _, name in ipairs(fs.getDirectoryItems(overlayPath(ctx, at.key)) or {}) do + add(name) + end + end + end + table.sort(out) + return out +end + +-- ------- buffered file handles + +local function openBuffer(ctx, path, mode) + mode = tostring(mode or "r"):gsub("b", "") + local writable = mode:find("[wa+]") ~= nil + local body + if mode:sub(1, 1) == "w" then + body = "" + else + body = readPath(ctx, path) + if not body then + if not writable then return nil, "could not open " .. tostring(path) end + body = "" + end + end + local state = { + ctx = ctx, path = path, buf = body, pos = 1, + writable = writable, closed = false, dirty = false, + } + if mode:sub(1, 1) == "a" then state.pos = #body + 1 end + return state +end + +local function bufferFlush(state) + if not (state.writable and state.dirty) then return true end + local ok, err = writePath(state.ctx, state.path, state.buf) + if ok then state.dirty = false end + return ok, err +end + +local function bufferWrite(state, text) + if not state.writable then return false, "file is not open for writing" end + local head = state.buf:sub(1, state.pos - 1) + if #head < state.pos - 1 then head = head .. string.rep("\0", state.pos - 1 - #head) end + local tail = state.buf:sub(state.pos + #text) + state.buf = head .. text .. tail + state.pos = state.pos + #text + state.dirty = true + return true +end + +local function bufferRead(state, fmt) + if fmt == nil then fmt = "*l" end + if type(fmt) == "number" then + if fmt == 0 then return state.pos <= #state.buf and "" or nil end + if state.pos > #state.buf then return nil end + local chunk = state.buf:sub(state.pos, state.pos + fmt - 1) + state.pos = state.pos + #chunk + return chunk + end + fmt = tostring(fmt):gsub("^%*", "") + if fmt == "a" then + local rest = state.buf:sub(state.pos) + state.pos = #state.buf + 1 + return rest + end + if fmt == "l" or fmt == "L" then + if state.pos > #state.buf then return nil end + local nl = state.buf:find("\n", state.pos, true) + local line + if nl then + line = state.buf:sub(state.pos, fmt == "L" and nl or nl - 1) + state.pos = nl + 1 + else + line = state.buf:sub(state.pos) + state.pos = #state.buf + 1 + end + return line + end + if fmt == "n" then + local rest = state.buf:sub(state.pos) + local text, after = rest:match("^%s*(%-?%d+%.?%d*[eE]?[-+]?%d*)()") + if not text then return nil end + state.pos = state.pos + after - 1 + return tonumber(text) + end + return nil +end + +local function bufferSeek(state, whence, offset) + whence = whence or "cur" + offset = offset or 0 + if whence == "set" then state.pos = offset + 1 + elseif whence == "cur" then state.pos = state.pos + offset + elseif whence == "end" then state.pos = #state.buf + offset + 1 + else return nil, "bad seek base" end + if state.pos < 1 then state.pos = 1 end + return state.pos - 1 +end + +local function bufferClose(state) + if state.closed then return true end + local ok, err = bufferFlush(state) + state.closed = true + return ok, err +end + +local function ioFile(ctx, path, mode) + local state, err = openBuffer(ctx, path, mode) + if not state then return nil, err end + local file = {} + function file:read(...) + local count = select("#", ...) + if count == 0 then return bufferRead(state, "*l") end + local out = {} + for i = 1, count do out[i] = bufferRead(state, (select(i, ...))) end + return unpack(out, 1, count) + end + function file:write(...) + for i = 1, select("#", ...) do + local value = select(i, ...) + local ok, writeErr = bufferWrite(state, tostring(value)) + if not ok then return nil, writeErr end + end + return file + end + function file:lines(fmt) + return function() return bufferRead(state, fmt or "*l") end + end + function file:seek(whence, offset) return bufferSeek(state, whence, offset) end + function file:flush() bufferFlush(state) return file end + function file:close() return bufferClose(state) end + function file:setvbuf() return true end + return file +end + +local function loveFile(ctx, path, mode) + local state = nil + local file = {} + function file:open(openMode) + local opened, err = openBuffer(ctx, path, openMode or mode or "r") + if not opened then return false, err end + state = opened + return true + end + function file:isOpen() return state ~= nil and not state.closed end + function file:read(bytes) + if not state and not file:open("r") then return nil, 0 end + local body = bytes and bufferRead(state, bytes) or bufferRead(state, "*a") + if not body then return nil, 0 end + return body, #body + end + function file:write(data, size) + if not state and not file:open(mode or "w") then return false end + data = tostring(data) + if size then data = data:sub(1, size) end + local ok, err = bufferWrite(state, data) + if not ok then return false, err end + bufferFlush(state) + return true + end + function file:lines() + if not state then file:open("r") end + return function() return state and bufferRead(state, "*l") or nil end + end + function file:seek(offset) + if not state then return false end + bufferSeek(state, "set", offset or 0) + return true + end + function file:tell() return state and (state.pos - 1) or 0 end + function file:getSize() + if state then return #state.buf end + local body = readPath(ctx, path) + return body and #body or 0 + end + function file:flush() if state then bufferFlush(state) end return true end + function file:close() + if state then bufferClose(state) end + state = nil + return true + end + function file:getFilename() return path end + function file:getMode() return mode or "c" end + function file:setBuffer() return true end + if mode and mode ~= "c" then file:open(mode) end + return file +end + +-- ------- the love.filesystem stand-in + +local function realFilesystem() + return _G.love and _G.love.filesystem or nil +end + +local function filesystemShim(ctx) + local fsShim = {} + + local function readAdvice() return "reads now come from mod:read and mod.storage" end + + function fsShim.read(a, b, c) + local path, size = a, b + if (a == "string" or a == "data") and type(b) == "string" then + path, size = b, c + end + note(ctx, "love.filesystem.read", readAdvice()) + local body, err = readPath(ctx, path) + if not body then return nil, err end + if size and size >= 0 then body = body:sub(1, size) end + return body, #body + end + + function fsShim.write(path, data, size) + note(ctx, "love.filesystem.write", + "writes are rerouted to this mod's private compat storage; migrate to mod.storage") + data = tostring(data) + if size then data = data:sub(1, size) end + local ok, err = writePath(ctx, path, data) + if not ok then return false, err end + return true + end + + function fsShim.append(path, data, size) + note(ctx, "love.filesystem.append", + "writes are rerouted to this mod's private compat storage; migrate to mod.storage") + data = tostring(data) + if size then data = data:sub(1, size) end + local existing = readPath(ctx, path) or "" + local ok, err = writePath(ctx, path, existing .. data) + if not ok then return false, err end + return true + end + + function fsShim.lines(path) + note(ctx, "love.filesystem.lines", readAdvice()) + local body = readPath(ctx, path) + if not body then error("could not open " .. tostring(path), 2) end + local pos = 1 + return function() + if pos > #body then return nil end + local nl = body:find("\n", pos, true) + local line + if nl then + line = body:sub(pos, nl - 1) + pos = nl + 1 + else + line = body:sub(pos) + pos = #body + 1 + end + return line + end + end + + function fsShim.load(path) + note(ctx, "love.filesystem.load", + "the chunk is compiled into this mod's sandbox; prefer require or mod:read plus load") + local body = readPath(ctx, path) + if not body then return nil, "could not open " .. tostring(path) end + if not ctx.compile then return nil, "no sandbox is bound yet" end + return ctx.compile(body, "@" .. tostring(path)) + end + + function fsShim.getInfo(path, a, b) + local info = infoPath(ctx, path) + local filter = type(a) == "string" and a or nil + local into = type(a) == "table" and a or (type(b) == "table" and b or nil) + if not info then return nil end + if filter and info.type ~= filter then return nil end + if into then + into.type, into.size, into.modtime = info.type, info.size, info.modtime + return into + end + return info + end + + function fsShim.getDirectoryItems(path) + note(ctx, "love.filesystem.getDirectoryItems", "use mod.assets:list") + return listPath(ctx, path) + end + + function fsShim.createDirectory(path) + local at = classify(ctx, path) + if not (at and at.key) then return false end + local fs = persistFs(ctx) + if not (fs and fs.createDirectory) then return false end + ensureParent(fs, overlayPath(ctx, at.key) .. "/.") + fs.createDirectory(overlayPath(ctx, at.key)) + return true + end + + function fsShim.remove(path) + local at = classify(ctx, path) + if not (at and at.key) then return false end + return overlayRemove(ctx, at.key) + end + + function fsShim.exists(path) return infoPath(ctx, path) ~= nil end + + function fsShim.isFile(path) + local info = infoPath(ctx, path) + return info ~= nil and info.type == "file" + end + + function fsShim.isDirectory(path) + local info = infoPath(ctx, path) + return info ~= nil and info.type == "directory" + end + + function fsShim.getSize(path) + local info = infoPath(ctx, path) + if not info then return nil, "could not open " .. tostring(path) end + return info.size or 0 + end + + function fsShim.getLastModified(path) + local info = infoPath(ctx, path) + if not info then return nil, "could not open " .. tostring(path) end + return info.modtime or 0 + end + + local function virtual(call) + note(ctx, call, "paths are virtual now; everything under the returned root " + .. "lands in this mod's private compat storage") + return ctx.virtualRoot + end + + function fsShim.getSaveDirectory() return virtual("love.filesystem.getSaveDirectory") end + function fsShim.getWorkingDirectory() return virtual("love.filesystem.getWorkingDirectory") end + function fsShim.getUserDirectory() return virtual("love.filesystem.getUserDirectory") end + function fsShim.getAppdataDirectory() return virtual("love.filesystem.getAppdataDirectory") end + function fsShim.getSourceBaseDirectory() return virtual("love.filesystem.getSourceBaseDirectory") end + function fsShim.getRealDirectory() return ctx.virtualRoot end + + function fsShim.getIdentity() return ctx.modId end + + function fsShim.setIdentity() + note(ctx, "love.filesystem.setIdentity", + "a mod cannot repoint the save directory; the call does nothing") + return false + end + + function fsShim.getRequirePath() return "" end + function fsShim.setRequirePath() return false end + function fsShim.getCRequirePath() return "" end + function fsShim.setCRequirePath() return false end + + function fsShim.mount() + note(ctx, "love.filesystem.mount", + "mounting is refused; ship the files inside your mod and use mod:read") + return false + end + fsShim.unmount = fsShim.mount + + function fsShim.newFile(path, mode) return loveFile(ctx, path, mode) end + + function fsShim.newFileData(a, b) + local real = realFilesystem() + if type(a) == "string" and b == nil then + note(ctx, "love.filesystem.newFileData", readAdvice()) + local body = readPath(ctx, a) + if not body then return nil, "could not open " .. tostring(a) end + if real and real.newFileData then return real.newFileData(body, a) end + return nil, "no filesystem" + end + if real and real.newFileData then return real.newFileData(a, b) end + return nil, "no filesystem" + end + + function fsShim.isFused() + local real = realFilesystem() + return real and real.isFused and real.isFused() or false + end + + function fsShim.areSymlinksEnabled() return false end + function fsShim.setSymlinksEnabled() return false end + function fsShim.init() return false end + function fsShim.setSource() return false end + + return setmetatable(fsShim, { __index = function(_, key) + note(ctx, "love.filesystem." .. tostring(key), + "there is no compat stand-in for it; use mod.storage or mod:read") + return nil + end }) +end + +-- ------- the rest of the removed surface + +local function systemShim(ctx) + local function real() return _G.love and _G.love.system or nil end + return { + getOS = function() + local sys = real() + return sys and sys.getOS and sys.getOS() or "Unknown" + end, + getPowerInfo = function() + local sys = real() + if not (sys and sys.getPowerInfo) then return "unknown", nil, nil end + return sys.getPowerInfo() + end, + getProcessorCount = function() + local sys = real() + return sys and sys.getProcessorCount and sys.getProcessorCount() or 1 + end, + getClipboardText = function() + note(ctx, "love.system.getClipboardText", + "clipboard access stays sandboxed; the call returns an empty string") + return "" + end, + setClipboardText = function() + note(ctx, "love.system.setClipboardText", + "clipboard access stays sandboxed; the call does nothing") + return false + end, + openURL = function() + note(ctx, "love.system.openURL", + "launching a URL stays sandboxed; the call does nothing") + return false + end, + vibrate = function(...) + local sys = real() + if sys and sys.vibrate then return sys.vibrate(...) end + return false + end, + } +end + +local function eventShim(ctx) + local function real() return _G.love and _G.love.event or nil end + local shim = { + quit = function() + note(ctx, "love.event.quit", + "a mod cannot close the game out from under the player; the call does nothing") + return false + end, + } + shim.push = function(name, ...) + if name == "quit" then return shim.quit() end + local ev = real() + if ev and ev.push then return ev.push(name, ...) end + return false + end + return setmetatable(shim, { __index = function(_, key) + local ev = real() + return ev and ev[key] or nil + end }) +end + +local function ioShim(ctx) + local stream = { + write = function(self, ...) + for i = 1, select("#", ...) do io.write(tostring((select(i, ...)))) end + return self + end, + flush = function(self) return self end, + close = function() return true end, + read = function() return nil end, + lines = function() return function() return nil end end, + seek = function() return 0 end, + setvbuf = function() return true end, + } + local out = setmetatable({}, { __index = stream }) + local shim = { + open = function(path, mode) + note(ctx, "io.open", + "the handle is backed by mod:read and this mod's private compat storage") + local file, err = ioFile(ctx, path, mode) + if not file then return nil, err, 2 end + return file + end, + lines = function(path, fmt) + if path == nil then return function() return nil end end + note(ctx, "io.lines", + "the handle is backed by mod:read and this mod's private compat storage") + local file, err = ioFile(ctx, path, "r") + if not file then error(err, 2) end + return file:lines(fmt) + end, + close = function(file) if file and file.close then return file:close() end return true end, + type = function(file) + if type(file) == "table" and file.read and file.close then return "file" end + return nil + end, + read = function() return nil end, + write = function(...) return out:write(...) end, + input = function() return out end, + output = function() return out end, + stdout = out, + stderr = out, + stdin = setmetatable({}, { __index = stream }), + popen = function() + return refuse(ctx, "io.popen", "spawning a process is refused") + end, + tmpfile = function() + return ioFile(ctx, ctx.virtualRoot .. "/io_tmpfile", "w+") + end, + } + return shim +end + +local function osShim(ctx) + local HOME = { HOME = true, APPDATA = true, LOCALAPPDATA = true, + USERPROFILE = true, XDG_DATA_HOME = true, + XDG_CONFIG_HOME = true, TMPDIR = true, TEMP = true, TMP = true } + return { + getenv = function(name) + note(ctx, "os.getenv", + "the environment is hidden; home-like names answer with this mod's virtual root") + if type(name) == "string" and HOME[name:upper()] then + return ctx.virtualRoot + end + return nil + end, + remove = function(path) + note(ctx, "os.remove", "the delete lands in this mod's private compat storage") + local at = classify(ctx, path) + if not (at and at.key and overlayRemove(ctx, at.key)) then + return nil, tostring(path) .. ": no such file" + end + return true + end, + rename = function(from, to) + note(ctx, "os.rename", "the move lands in this mod's private compat storage") + local body = readPath(ctx, from) + if not body then return nil, tostring(from) .. ": no such file" end + local ok, err = writePath(ctx, to, body) + if not ok then return nil, err end + local at = classify(ctx, from) + if at and at.key then overlayRemove(ctx, at.key) end + return true + end, + tmpname = function() + return ctx.virtualRoot .. "/tmp/os_tmpname" + end, + execute = function() + note(ctx, "os.execute", "running a command is refused") + return false + end, + exit = function() + note(ctx, "os.exit", + "a mod cannot end the process; the call does nothing") + return false + end, + setlocale = function() return "C" end, + } +end + +local function packageShim(ctx) + local shim = { path = "", cpath = "", preload = {}, loaded = {}, loaders = {} } + return setmetatable(shim, { + __index = function(_, key) + note(ctx, "package." .. tostring(key), + "the module loader is sandboxed; use require for the supported engine modules") + return nil + end, + }) +end + +-- ------- love callbacks + +local CALLBACKS = { + load = true, update = true, draw = true, quit = true, lowmemory = true, + threaderror = true, keypressed = true, keyreleased = true, + textinput = true, textedited = true, mousemoved = true, + mousepressed = true, mousereleased = true, wheelmoved = true, + mousefocus = true, touchpressed = true, touchreleased = true, + touchmoved = true, joystickadded = true, joystickremoved = true, + joystickpressed = true, joystickreleased = true, joystickaxis = true, + joystickhat = true, gamepadpressed = true, gamepadreleased = true, + gamepadaxis = true, focus = true, visible = true, resize = true, + filedropped = true, directorydropped = true, displayrotated = true, + audiodevicechanged = true, localechanged = true, +} + +local REFUSED = { + run = "love.run is the engine's fixed-step loop; use mod.hooks and mod.events", + errorhandler = "love.errorhandler is how a crash reaches the player; " + .. "use mod.events", +} + +-- ------- assembly + +function LegacyCompat.new(opts) + opts = opts or {} + local ctx = { + modId = opts.modId or "mod", + modPath = opts.modPath, + fs = opts.fs, + game = opts.game, + compile = nil, + } + ctx.virtualRoot = "/pokeport/" .. ctx.modId + + local filesystem = filesystemShim(ctx) + local io_ = ioShim(ctx) + local system = systemShim(ctx) + local event = eventShim(ctx) + + local compat = { + ctx = ctx, + love = { filesystem = filesystem, system = system, event = event }, + os = osShim(ctx), + globals = { + io = io_, + package = packageShim(ctx), + loadfile = function(path) + note(ctx, "loadfile", + "the chunk is compiled into this mod's sandbox; prefer require or mod:read") + local body = readPath(ctx, path) + if not body then return nil, "could not open " .. tostring(path) end + if not ctx.compile then return nil, "no sandbox is bound yet" end + return ctx.compile(body, "@" .. tostring(path)) + end, + }, + modules = { + io = io_, + ["love.filesystem"] = filesystem, + ["love.system"] = system, + ["love.event"] = event, + }, + } + + compat.globals.dofile = function(path) + local chunk, err = compat.globals.loadfile(path) + if not chunk then error(err or ("could not open " .. tostring(path)), 2) end + return chunk() + end + + function compat.module(name) + if type(name) ~= "string" then return nil end + local substitute = compat.modules[name] + if substitute then + note(ctx, ('require("%s")'):format(name), + "it now answers with the compat stand-in, not the real module") + return substitute + end + if name == "os" then + note(ctx, 'require("os")', + "it now answers with the compat stand-in, not the real module") + return compat.osTable + end + return nil + end + + -- true when the assignment is a callback chain, which is what a mod + -- wrapping love.mousemoved did before the sandbox; false plus a reason for + -- the two names the engine owns, false alone for a module table. + function compat.assign(key, value) + if REFUSED[key] then + note(ctx, ("love.%s assignment"):format(key), REFUSED[key]) + return false, ("[%s] %s"):format(ctx.modId, REFUSED[key]) + end + if not CALLBACKS[key] then return false end + if value ~= nil and type(value) ~= "function" then return false end + note(ctx, ("love.%s assignment"):format(key), + "the callback lands on the real love table the way it did before the " + .. "sandbox; prefer mod.hooks and mod.events") + return true + end + + function compat.bind(env, compile) + ctx.compile = compile + compat.osTable = env and env.os or nil + end + + return compat +end + +return LegacyCompat diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 1a46dbf9..09bb7e2c 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -20,6 +20,7 @@ local Semver = require("src.mods.Semver") local Events = require("src.mods.Events") local Gen2Compat = require("src.mods.Gen2Compat") local Hooks = require("src.mods.Hooks") +local LegacyCompat = require("src.mods.LegacyCompat") local Runtime = require("src.mods.Runtime") local Steps = require("src.mods.Steps") @@ -1288,12 +1289,24 @@ function Loader:_modEnv(mod) local id = mod.manifest.id local env = self.modEnv[id] if not env then - env = Sandbox.envFor({ modId = id, permissions = mod.manifest.permissionSet }) + local loader = self + local compat = LegacyCompat.new({ + modId = id, modPath = mod.path, fs = self.fs, + game = function() return loader:_game() end, + }) + env = Sandbox.envFor({ modId = id, permissions = mod.manifest.permissionSet, + compat = compat }) self.modEnv[id] = env end return env end +-- Which pre-sandbox calls each loaded mod actually took, for the manager's +-- "needs updating" badge; nil id answers for every mod. +function Loader:legacyReport(modId) + return LegacyCompat.report(modId) +end + function Loader:_loadMod(mod) local path = SafePath.join(mod.path, mod.manifest.entry, "manifest entry") local chunk, err = Sandbox.loadFile(self.fs, path, self:_modEnv(mod)) diff --git a/src/mods/Sandbox.lua b/src/mods/Sandbox.lua index b6cb61dd..328b0547 100644 --- a/src/mods/Sandbox.lua +++ b/src/mods/Sandbox.lua @@ -73,11 +73,15 @@ local BLOCKED_LOVE = { .. "the step bridge", event = true, } -local loveProxy -local function loveFacade() - if loveProxy or not _G.love then return loveProxy end - loveProxy = setmetatable({}, { +-- Per-mod, because the compat overrides (src/mods/LegacyCompat.lua) are backed +-- by that mod's own overlay and must not be shared. +local function loveFacade(compat) + if not _G.love then return nil end + local overrides = compat and compat.love + return setmetatable({}, { __index = function(_, key) + local override = overrides and overrides[key] + if override ~= nil then return override end local hint = BLOCKED_LOVE[key] if hint then error(("love.%s is not available to mods%s"):format(key, @@ -85,11 +89,20 @@ local function loveFacade() end return _G.love[key] end, - __newindex = function(_, key) + -- a callback chain lands on the real table (compat.assign decides which + -- names qualify); a module table never does + __newindex = function(_, key, value) + if compat then + local allowed, reason = compat.assign(key, value) + if allowed then + _G.love[key] = value + return + end + if reason then error(reason, 2) end + end error(("mods cannot assign love.%s"):format(tostring(key)), 2) end, }) - return loveProxy end -- ------- the environment @@ -177,8 +190,12 @@ end -- Runtime.modRequire is how the loader's gate identifies the caller for the -- Gen 2 facade once Runtime.currentMod has gone back to nil (a mod requiring -- lazily from an event handler). -local function sandboxedRequire(modId, permissionSet) +local function sandboxedRequire(modId, permissionSet, compat) return function(name, ...) + -- the compat stand-in answers first, so a legacy require("io") gets the + -- rerouted table instead of the denial below (src/mods/LegacyCompat.lua) + local substitute = compat and compat.module(name) + if substitute ~= nil then return substitute end local denial = Sandbox.moduleDenial(name, permissionSet) if denial then error(("[%s] %s"):format(modId or "mod", denial), 2) end local previous = Runtime.modRequire @@ -192,15 +209,23 @@ end function Sandbox.envFor(opts) opts = opts or {} + local compat = opts.compat local env = baseGlobals() - env.love = loveFacade() - env.require = sandboxedRequire(opts.modId, opts.permissions) + env.love = loveFacade(compat) + env.require = sandboxedRequire(opts.modId, opts.permissions, compat) local loader = sandboxedLoad(env) env.load = loader env.loadstring = loader -- a mod's globals are its own: two mods no longer share a namespace, and -- neither can reach the engine's env._G = env + if compat then + for key, value in pairs(compat.globals) do env[key] = value end + for key, value in pairs(compat.os) do env.os[key] = value end + compat.bind(env, function(source, chunkname) + return Sandbox.compile(source, chunkname, env) + end) + end return env end diff --git a/tests/fs_io.lua b/tests/fs_io.lua index 688bb790..f05a34af 100644 --- a/tests/fs_io.lua +++ b/tests/fs_io.lua @@ -138,6 +138,15 @@ function FsIo.new(rootDir) return loadfile(abs(path)) end + function fs.createDirectory(path) + os.execute(("mkdir -p %q"):format(abs(path))) + return true + end + + function fs.remove(path) + return os.remove(abs(path)) ~= nil + end + function fs.getDirectoryItems(path) return FsIo.listDir(abs(path)) end diff --git a/tests/modkit/cases/sandbox.lua b/tests/modkit/cases/sandbox.lua index e021e1bb..4c7aba9b 100644 --- a/tests/modkit/cases/sandbox.lua +++ b/tests/modkit/cases/sandbox.lua @@ -1,7 +1,8 @@ --- T4: the mod sandbox (src/mods/Sandbox.lua). A mod's own chunks run against --- an environment with no io, no os beyond the clock, and no way to name a path --- outside its own directory, so a mod cannot reach the player's filesystem. --- Every case here is an escape a mod would actually try. +-- 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 @@ -9,6 +10,7 @@ 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",' @@ -20,16 +22,14 @@ end local PROBE = [[ local mod = ... local out = mod.exports - out.io = io - out.package = package - out.dofile = dofile - out.loadfile = loadfile + 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 = os.getenv - out.osExecute = os.execute - out.osRemove = os.remove + out.osGetenv = type(os.getenv) out.osTime = type(os.time) out.stringOk = ("a"):rep(3) @@ -38,30 +38,86 @@ local PROBE = [[ if ok then return false end return tostring(err) end - out.requireIo = attempt(require, "io") - out.requireOs = attempt(require, "os") + 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.requireLoveFs = attempt(require, "love.filesystem") out.requireSocket = attempt(require, "socket") - -- called from a nested Lua frame rather than straight off pcall, which is - -- the shape a stack-walking gate reads differently - out.requireIoNested = attempt(function() return require("io") end) out.requireSemver = select(2, pcall(require, "src.mods.Semver")) - out.loveFilesystem = attempt(function() return love.filesystem end) out.loveThread = attempt(function() return love.thread end) - out.loveSystem = attempt(function() return love.system 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.childIo = childIo - out.childGetenv = childGetenv + out.childIoIsShim = childIo == io + out.childGetenvIsShim = childGetenv == os.getenv out.childSharesEnv = childG == _G out.readEscape = attempt(function() return mod:read("../../secret.txt") end) @@ -95,58 +151,121 @@ local FILES = { ["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 standard library a mod does not get +-- ------- the pre-sandbox globals are stand-ins, not the real thing -T.eq(out.io, nil, "io is absent from the mod environment") -T.eq(out.package, nil, "package is absent, so package.loaded is unreachable") -T.eq(out.dofile, nil, "dofile is absent") -T.eq(out.loadfile, nil, "loadfile is absent") -T.eq(out.setfenv, nil, "setfenv is absent, so a mod cannot swap its own env") -T.eq(out.getfenv, nil, "getfenv is absent, so a mod cannot read the real _G out") -T.eq(out.debug, nil, "the debug library is absent") -T.eq(out.osGetenv, nil, "os.getenv is absent -- it is how the report's exploit " - .. "found the user's home directory") -T.eq(out.osExecute, nil, "os.execute is absent") -T.eq(out.osRemove, nil, "os.remove is absent") -T.eq(out.osTime, "function", "os.time still works: the clock is not the hole") +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") --- ------- require, the one call that would undo all of the above +-- 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") -T.check(out.requireIo and out.requireIo:find("not available to mods", 1, true), - "require(\"io\") is refused: " .. tostring(out.requireIo)) -T.check(out.requireOs ~= false, "require(\"os\") is refused") -T.check(out.requireDebug ~= false, "require(\"debug\") is refused") -T.check(out.requirePackage ~= false, "require(\"package\") is refused") -T.check(out.requireFfi ~= false, "require(\"ffi\") is refused: it is arbitrary C") -T.check(out.requireLoveFs ~= false, "require(\"love.filesystem\") is refused") -T.check(out.requireIoNested ~= false, - "require(\"io\") from a nested frame is refused the same way") +-- ------- 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 names the permission it needs: " .. tostring(out.requireSocket)) + "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.check(out.loveFilesystem and out.loveFilesystem:find("mod.storage", 1, true), - "love.filesystem is refused and names the replacement") -T.check(out.loveThread ~= false, "love.thread is refused: it opens a full Lua state") -T.check(out.loveSystem and out.loveSystem:find("mod.device:powerInfo()", 1, true), - "love.system is refused and names the scoped power replacement") T.eq(out.loveGraphics, "table", "the rest of love passes through") -T.check(out.loveAssign ~= false, "a mod cannot assign into the love facade") +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.eq(out.childIo, nil, +T.check(out.childIoIsShim, "a chunk a mod load()s inherits the sandbox (5.1 would hand it the real _G)") -T.eq(out.childGetenv, nil, "the child chunk gets the same reduced os") +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") @@ -187,6 +306,36 @@ 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", @@ -226,11 +375,11 @@ do bytecodeRun.release() end --- ------- the sandbox is not opt-in +-- ------- 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 is already closed") + 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