Compare commits

..

5 Commits

Author SHA1 Message Date
bryanthaboi a86d57ac44 Merge pull request #1316 from bryanthaboi/dev 2026-08-14 21:44:05 -04:00
github-actions c9d67582ae chore(ios): update app-repo.json [skip ci] 2026-08-14 18:02:54 -04:00
bryanthaboi 1cfd91a503 Merge pull request #1309 from bryanthaboi/dev
path read
2026-08-14 17:54:26 -04:00
github-actions c0ad6d0328 chore(ios): update app-repo.json [skip ci] 2026-08-14 17:19:40 -04:00
bryanthaboi 84de8c9cb1 Merge pull request #1306 from bryanthaboi/dev
for greg
2026-08-14 17:10:29 -04:00
13 changed files with 96 additions and 1359 deletions
+11 -35
View File
@@ -246,37 +246,17 @@ real Gold boot.
Your code runs in a sandbox (`src/mods/Sandbox.lua`), not against the 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 engine's globals. Every chunk you author gets it: `main.lua`, your
`options_schema`, and anything you `load()` yourself. `options_schema`, and anything you `load()` yourself. What is absent:
The globals the sandbox took away are still *reachable*, as compat | Absent | Use instead |
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/<your id>/` | `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 |
| --- | --- | | --- | --- |
| `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 | | `io`, and `require("io")` | `mod:read` for your own files, `mod.storage` to persist |
| `require("ffi")` | arbitrary C | | `os.getenv`, `os.execute`, `os.remove`, `os.rename`, `os.exit` | nothing; `os.time`/`os.date`/`os.clock` still work |
| `debug`, `getfenv`, `setfenv` | each one undoes the sandbox from inside | | `package`, `dofile`, `loadfile`, `debug`, `getfenv`, `setfenv` | `require` for the supported engine modules |
| `io.popen`, `os.execute` | spawning a process | | `require("ffi")`, `require("love.*")` | the `love` table you are given |
| `love.run`, `love.errorhandler` | the engine's own loop and its crash path | | `love.filesystem` | `mod.storage` (per-mod, per-playthrough), `mod:read` for a known file, `mod:list` / `mod:info` to iterate your own directory |
| replacing a `love` module table (`love.filesystem = {}`) | the engine reads those tables too | | `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 |
The rest of `love` passes through unchanged, so graphics, audio, timers and The rest of `love` passes through unchanged, so graphics, audio, timers and
input work as they always have. input work as they always have.
@@ -303,12 +283,8 @@ permission that grants raw filesystem access, because no mod needs one:
everything a mod legitimately writes is already scoped by everything a mod legitimately writes is already scoped by
`mod.storage` or the asset-transform derived root. `mod.storage` or the asset-transform derived root.
If your mod used one of the rerouted globals, the fix is almost always If your mod used one of the absent globals, the fix is almost always
`mod.storage`. The overlay is a compatibility floor, not a second storage `mod.storage`. Open an issue if you have a case it does not cover.
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` ### 6. `mod.card`
-34
View File
@@ -802,37 +802,3 @@ 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 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 is never delivered twice. Without the permission, `sync` and `poll` raise
an error naming it. 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/<your id>/` 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.
-1
View File
@@ -40,4 +40,3 @@ 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 * **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
+14
View File
@@ -12,6 +12,20 @@
"tintColor": "3b5ca8", "tintColor": "3b5ca8",
"category": "games", "category": "games",
"versions": [ "versions": [
{
"version": "0.1.88",
"date": "2026-08-14",
"size": 11311237,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.88/gen1recomp++-0.1.88-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.1.87",
"date": "2026-08-14",
"size": 11310910,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.87/gen1recomp++-0.1.87-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi\n- @mleo2003\n- @ShaneMcGovernIE\n- MaxTomahawk"
},
{ {
"version": "0.1.86", "version": "0.1.86",
"date": "2026-08-14", "date": "2026-08-14",
+2 -49
View File
@@ -832,41 +832,9 @@ local function tryMigrateLegacy(version, fs)
return id return id
end end
-- Scan the filesystem for orphaned slot files under saves/<version>/ when options.lua
-- has no registered slots for this version (e.g. options.lua was reset or lost).
local function scanDiskSlots(version, fs)
if not fs then return nil end
local dir = "saves/" .. version
local slots = {}
if fs.getDirectoryItems and fs.getInfo and fs.getInfo(dir) then
local items = pcall(fs.getDirectoryItems, dir) and fs.getDirectoryItems(dir) or {}
local numbers = {}
for _, item in ipairs(items) do
local slotId = item:match("^(slot%d+)%.lua$")
if slotId then
local n = tonumber(slotId:match("%d+"))
table.insert(numbers, { id = slotId, num = n or 0 })
end
end
table.sort(numbers, function(a, b) return a.num < b.num end)
for _, item in ipairs(numbers) do
table.insert(slots, item.id)
end
else
for i = 1, 30 do
local slotId = "slot" .. i
local path = dir .. "/" .. slotId .. ".lua"
if fs.getInfo and fs.getInfo(path) then
table.insert(slots, slotId)
end
end
end
return #slots > 0 and slots or nil
end
-- Resolve (once per version per process) which slot in-game saves use: an -- Resolve (once per version per process) which slot in-game saves use: an
-- existing registry wins; otherwise a lazy legacy migration may create -- existing registry wins; otherwise a lazy legacy migration may create
-- slot1; otherwise auto-recover disk slots; otherwise false (flat legacy path). -- slot1; otherwise false, meaning the flat legacy path.
local function ensureVersionSlots(version, fs) local function ensureVersionSlots(version, fs)
if slotsChecked[version] then return end if slotsChecked[version] then return end
slotsChecked[version] = true slotsChecked[version] = true
@@ -880,22 +848,7 @@ local function ensureVersionSlots(version, fs)
activeSlotCache[version] = reg.active or reg.list[1] activeSlotCache[version] = reg.active or reg.list[1]
return return
end end
local migrated = tryMigrateLegacy(version, fs) activeSlotCache[version] = tryMigrateLegacy(version, fs) or false
if migrated then
activeSlotCache[version] = migrated
return
end
-- Auto-recovery: if options.lua lost its slot registry, scan disk for orphaned slot files
local recovered = scanDiskSlots(version, fs)
if recovered and #recovered > 0 then
opts.saveSlots = opts.saveSlots or {}
opts.saveSlots[version] = { list = recovered, active = recovered[1] }
SaveData.saveOptions(opts, fs)
activeSlotCache[version] = recovered[1]
Logger.info("auto-recovered %d save slot(s) for %s from disk", #recovered, version)
return
end
activeSlotCache[version] = false
end end
-- (body for the forward-declared saveNames.) Resolves the ACTIVE slot for -- (body for the forward-declared saveNames.) Resolves the ACTIVE slot for
-943
View File
@@ -1,943 +0,0 @@
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
+1 -14
View File
@@ -20,7 +20,6 @@ local Semver = require("src.mods.Semver")
local Events = require("src.mods.Events") local Events = require("src.mods.Events")
local Gen2Compat = require("src.mods.Gen2Compat") local Gen2Compat = require("src.mods.Gen2Compat")
local Hooks = require("src.mods.Hooks") local Hooks = require("src.mods.Hooks")
local LegacyCompat = require("src.mods.LegacyCompat")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local Steps = require("src.mods.Steps") local Steps = require("src.mods.Steps")
@@ -1289,24 +1288,12 @@ function Loader:_modEnv(mod)
local id = mod.manifest.id local id = mod.manifest.id
local env = self.modEnv[id] local env = self.modEnv[id]
if not env then if not env then
local loader = self env = Sandbox.envFor({ modId = id, permissions = mod.manifest.permissionSet })
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 self.modEnv[id] = env
end end
return env return env
end 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) function Loader:_loadMod(mod)
local path = SafePath.join(mod.path, mod.manifest.entry, "manifest entry") local path = SafePath.join(mod.path, mod.manifest.entry, "manifest entry")
local chunk, err = Sandbox.loadFile(self.fs, path, self:_modEnv(mod)) local chunk, err = Sandbox.loadFile(self.fs, path, self:_modEnv(mod))
+9 -34
View File
@@ -73,15 +73,11 @@ local BLOCKED_LOVE = {
.. "the step bridge", event = true, .. "the step bridge", event = true,
} }
-- Per-mod, because the compat overrides (src/mods/LegacyCompat.lua) are backed local loveProxy
-- by that mod's own overlay and must not be shared. local function loveFacade()
local function loveFacade(compat) if loveProxy or not _G.love then return loveProxy end
if not _G.love then return nil end loveProxy = setmetatable({}, {
local overrides = compat and compat.love
return setmetatable({}, {
__index = function(_, key) __index = function(_, key)
local override = overrides and overrides[key]
if override ~= nil then return override end
local hint = BLOCKED_LOVE[key] local hint = BLOCKED_LOVE[key]
if hint then if hint then
error(("love.%s is not available to mods%s"):format(key, error(("love.%s is not available to mods%s"):format(key,
@@ -89,20 +85,11 @@ local function loveFacade(compat)
end end
return _G.love[key] return _G.love[key]
end, end,
-- a callback chain lands on the real table (compat.assign decides which __newindex = function(_, key)
-- 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) error(("mods cannot assign love.%s"):format(tostring(key)), 2)
end, end,
}) })
return loveProxy
end end
-- ------- the environment -- ------- the environment
@@ -190,12 +177,8 @@ end
-- Runtime.modRequire is how the loader's gate identifies the caller for the -- 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 -- Gen 2 facade once Runtime.currentMod has gone back to nil (a mod requiring
-- lazily from an event handler). -- lazily from an event handler).
local function sandboxedRequire(modId, permissionSet, compat) local function sandboxedRequire(modId, permissionSet)
return function(name, ...) 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) local denial = Sandbox.moduleDenial(name, permissionSet)
if denial then error(("[%s] %s"):format(modId or "mod", denial), 2) end if denial then error(("[%s] %s"):format(modId or "mod", denial), 2) end
local previous = Runtime.modRequire local previous = Runtime.modRequire
@@ -209,23 +192,15 @@ end
function Sandbox.envFor(opts) function Sandbox.envFor(opts)
opts = opts or {} opts = opts or {}
local compat = opts.compat
local env = baseGlobals() local env = baseGlobals()
env.love = loveFacade(compat) env.love = loveFacade()
env.require = sandboxedRequire(opts.modId, opts.permissions, compat) env.require = sandboxedRequire(opts.modId, opts.permissions)
local loader = sandboxedLoad(env) local loader = sandboxedLoad(env)
env.load = loader env.load = loader
env.loadstring = loader env.loadstring = loader
-- a mod's globals are its own: two mods no longer share a namespace, and -- a mod's globals are its own: two mods no longer share a namespace, and
-- neither can reach the engine's -- neither can reach the engine's
env._G = env 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 return env
end end
+1 -8
View File
@@ -539,19 +539,12 @@ function PartyMenu:update(dt)
-- .strength, GBPalWhiteOutWithDelay3 blinks the screen white -- .strength, GBPalWhiteOutWithDelay3 blinks the screen white
-- before CloseTextDisplay returns to the map. -- before CloseTextDisplay returns to the map.
local ow = self.game.overworld local ow = self.game.overworld
if ow and ow.useStrengthFieldMove then if ow and not ow:partyKnows("STRENGTH") then
if not ow:partyKnows("STRENGTH") then
refuseBadge(self) refuseBadge(self)
return return
end end
ow:useStrengthFieldMove(mon, function() self:close() end) ow:useStrengthFieldMove(mon, function() self:close() end)
return return
elseif ow and ow.useFieldMove then
ow:useFieldMove("STRENGTH", mon)
self:close()
return
end
return
elseif action == "softboiled" then elseif action == "softboiled" then
-- field SOFTBOILED (StartMenu_Pokemon .softboiled): transfer -- field SOFTBOILED (StartMenu_Pokemon .softboiled): transfer
-- 1/5 of the user's max HP to a chosen teammate -- 1/5 of the user's max HP to a chosen teammate
+1 -1
View File
@@ -62,7 +62,7 @@ end
do do
local body, ver = PatchNotes.body(nil) local body, ver = PatchNotes.body(nil)
check(type(body) == "string" and (body:find("Download", 1, true) or body:find("Issues", 1, true)), check(type(body) == "string" and body:find("Issues closed", 1, true),
"without a check result PatchNotes uses the stashed iOS app-repo notes") "without a check result PatchNotes uses the stashed iOS app-repo notes")
check(type(ver) == "string" and ver:find("^%d+%.%d+%.%d+$") ~= nil, check(type(ver) == "string" and ver:find("^%d+%.%d+%.%d+$") ~= nil,
"stashed notes name a release version") "stashed notes name a release version")
-9
View File
@@ -138,15 +138,6 @@ function FsIo.new(rootDir)
return loadfile(abs(path)) return loadfile(abs(path))
end 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) function fs.getDirectoryItems(path)
return FsIo.listDir(abs(path)) return FsIo.listDir(abs(path))
end end
+55 -204
View File
@@ -1,8 +1,7 @@
-- T4: the mod sandbox (src/mods/Sandbox.lua) and the compat reroute over it -- T4: the mod sandbox (src/mods/Sandbox.lua). A mod's own chunks run against
-- (src/mods/LegacyCompat.lua). A mod's own chunks still cannot name a path -- an environment with no io, no os beyond the clock, and no way to name a path
-- outside their own directory: the pre-sandbox globals are back as stand-ins -- outside its own directory, so a mod cannot reach the player's filesystem.
-- whose reads come from the mod's own files and whose writes land in a private -- Every case here is an escape a mod would actually try.
-- per-mod overlay. Every case here is an escape a mod would actually try.
package.path = "./?.lua;./?/init.lua;" .. package.path package.path = "./?.lua;./?/init.lua;" .. package.path
@@ -10,7 +9,6 @@ local T = require("tests.modkit")
local Manifest = require("src.mods.Manifest") local Manifest = require("src.mods.Manifest")
local Sandbox = require("src.mods.Sandbox") local Sandbox = require("src.mods.Sandbox")
local SafePath = require("src.mods.SafePath") local SafePath = require("src.mods.SafePath")
local LegacyCompat = require("src.mods.LegacyCompat")
local function manifest(id, extra) local function manifest(id, extra)
return ('{"id":"%s","name":"%s","version":"1.0.0","entry":"main.lua",' return ('{"id":"%s","name":"%s","version":"1.0.0","entry":"main.lua",'
@@ -22,14 +20,16 @@ end
local PROBE = [[ local PROBE = [[
local mod = ... local mod = ...
local out = mod.exports local out = mod.exports
out.io = type(io) out.io = io
out.package = type(package) out.package = package
out.dofile = type(dofile) out.dofile = dofile
out.loadfile = type(loadfile) out.loadfile = loadfile
out.setfenv = setfenv out.setfenv = setfenv
out.getfenv = getfenv out.getfenv = getfenv
out.debug = debug out.debug = debug
out.osGetenv = type(os.getenv) out.osGetenv = os.getenv
out.osExecute = os.execute
out.osRemove = os.remove
out.osTime = type(os.time) out.osTime = type(os.time)
out.stringOk = ("a"):rep(3) out.stringOk = ("a"):rep(3)
@@ -38,86 +38,30 @@ local PROBE = [[
if ok then return false end if ok then return false end
return tostring(err) return tostring(err)
end end
out.requireIoIsShim = select(2, pcall(require, "io")) == io out.requireIo = attempt(require, "io")
out.requireLoveFsIsShim = out.requireOs = attempt(require, "os")
select(2, pcall(require, "love.filesystem")) == love.filesystem
out.requireDebug = attempt(require, "debug") out.requireDebug = attempt(require, "debug")
out.requirePackage = attempt(require, "package") out.requirePackage = attempt(require, "package")
out.requireFfi = attempt(require, "ffi") out.requireFfi = attempt(require, "ffi")
out.requireLoveFs = attempt(require, "love.filesystem")
out.requireSocket = attempt(require, "socket") 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.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.loveThread = attempt(function() return love.thread end)
out.loveSystem = attempt(function() return love.system end)
out.loveGraphics = type(love.graphics) out.loveGraphics = type(love.graphics)
out.loveAssign = attempt(function() love.filesystem = {} end) 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 -- 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 -- mod's own source must inherit the sandbox, not the real globals
local child = load("return io, os.getenv, _G") local child = load("return io, os.getenv, _G")
local childIo, childGetenv, childG = child() local childIo, childGetenv, childG = child()
out.childIoIsShim = childIo == io out.childIo = childIo
out.childGetenvIsShim = childGetenv == os.getenv out.childGetenv = childGetenv
out.childSharesEnv = childG == _G out.childSharesEnv = childG == _G
out.readEscape = attempt(function() return mod:read("../../secret.txt") end) out.readEscape = attempt(function() return mod:read("../../secret.txt") end)
@@ -151,121 +95,58 @@ local FILES = {
["mods/fix_sandbox/assets/sprites/walk.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 run = T.sdk.loadMods({ "mods/fix_sandbox" }, { fs = T.sdk.memfs(FILES) })
local installedMouseMoved = love.mousemoved
love.mousemoved = savedMouseMoved
T.eq(#run.errors, 0, T.eq(#run.errors, 0,
"the probe mod loads clean (" .. tostring(run.errors[1]) .. ")") "the probe mod loads clean (" .. tostring(run.errors[1]) .. ")")
local out = run.loader.exports.fix_sandbox or {} local out = run.loader.exports.fix_sandbox or {}
-- ------- the pre-sandbox globals are stand-ins, not the real thing -- ------- the standard library a mod does not get
T.eq(out.io, "table", "io is present again, as the compat stand-in") T.eq(out.io, nil, "io is absent from the mod environment")
T.eq(out.package, "table", "so is package, as an inert stub") T.eq(out.package, nil, "package is absent, so package.loaded is unreachable")
T.eq(out.dofile, "function", "dofile routes through the stand-in") T.eq(out.dofile, nil, "dofile is absent")
T.eq(out.loadfile, "function", "so does loadfile") T.eq(out.loadfile, nil, "loadfile is absent")
T.eq(out.osGetenv, "function", "os.getenv answers rather than crashing the mod") T.eq(out.setfenv, nil, "setfenv is absent, so a mod cannot swap its own env")
T.eq(out.osTime, "function", "os.time still works: the clock was never the hole") 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.stringOk, "aaa", "the safe standard library is intact") 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 -- ------- require, the one call that would undo all of the above
-- 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.requireIo and out.requireIo:find("not available to mods", 1, true),
"require(\"io\") is refused: " .. tostring(out.requireIo))
T.check(out.requireIoIsShim, "require(\"io\") hands back the same compat table") T.check(out.requireOs ~= false, "require(\"os\") is refused")
T.check(out.requireLoveFsIsShim, T.check(out.requireDebug ~= false, "require(\"debug\") is refused")
"require(\"love.filesystem\") hands back the same compat table") 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")
T.check(out.requireSocket and out.requireSocket:find("network", 1, true), T.check(out.requireSocket and out.requireSocket:find("network", 1, true),
"a network module still names the permission it needs: " .. tostring(out.requireSocket)) "a network module names the permission it needs: " .. tostring(out.requireSocket))
T.eq(type(out.requireSemver), "table", T.eq(type(out.requireSemver), "table",
"the supported engine requires still resolve") "the supported engine requires still resolve")
-- ------- the love facade -- ------- 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.eq(out.loveGraphics, "table", "the rest of love passes through")
T.check(out.loveAssign ~= false, T.check(out.loveAssign ~= false, "a mod cannot assign into the love facade")
"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 -- ------- env propagation and isolation
T.check(out.childIoIsShim, T.eq(out.childIo, nil,
"a chunk a mod load()s inherits the sandbox (5.1 would hand it the real _G)") "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.eq(out.childGetenv, nil, "the child chunk gets the same reduced os")
T.check(out.childSharesEnv, "the child chunk shares the mod's own globals table") 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.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(_G.SANDBOX_LEAK, nil, "and never reach the engine's _G")
@@ -306,36 +187,6 @@ T.check(out.infoEscape and out.infoEscape:find("must stay inside", 1, true),
"mod:info cannot climb either") "mod:info cannot climb either")
run.release() 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 -- ------- the grammar itself
for _, bad in ipairs({ "../x", "a/../../x", "/etc/hosts", "C:/Windows/x", for _, bad in ipairs({ "../x", "a/../../x", "/etc/hosts", "C:/Windows/x",
@@ -375,11 +226,11 @@ do
bytecodeRun.release() bytecodeRun.release()
end end
-- ------- the sandbox with no compat layer is still closed -- ------- the sandbox is not opt-in
do do
local env = Sandbox.envFor({ modId = "probe" }) local env = Sandbox.envFor({ modId = "probe" })
T.eq(env.io, nil, "a bare Sandbox.envFor has no io") T.eq(env.io, nil, "a bare Sandbox.envFor is already closed")
T.eq(env._G, env, "_G points at the sandbox, not the real globals") 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") T.check(not pcall(env.require, "io"), "and its require refuses io")
end end
-25
View File
@@ -348,30 +348,5 @@ do
"MapPreview attaches a draw for a Gold map") "MapPreview attaches a draw for a Gold map")
end end
do
local memfs = {
files = {
["saves/gold/slot1.lua"] = 'return { version = "gold", generation = 2, player = { name = "GOLD" } }',
["options.lua"] = 'return { textSpeed = 3 }',
},
getInfo = function(self, path)
return self.files[path] and { type = "file" } or nil
end,
read = function(self, path)
return self.files[path]
end,
write = function(self, path, data)
self.files[path] = data
return true
end,
remove = function(self, path)
self.files[path] = nil
return true
end,
}
local main, _, _ = SaveData.saveFilename("gold")
check(main ~= nil, "saveFilename resolves for gold")
end
print(string.format("save editor gen2 tests: %d passed, %d failed", passed, failed)) print(string.format("save editor gen2 tests: %d passed, %d failed", passed, failed))
if failed > 0 then os.exit(1) end if failed > 0 then os.exit(1) end