grandmas-kitchn

This commit is contained in:
bryanthaboi
2026-08-12 09:22:30 -04:00
parent 49d094b14d
commit 83682f011d
21 changed files with 794 additions and 144 deletions
+42 -2
View File
@@ -242,7 +242,47 @@ with a `MK4xx` finding per site and an `unresolved:` note, with a file and a
line, for every reach a static scan could not follow. Neither substitutes for a line, for every reach a static scan could not follow. Neither substitutes for a
real Gold boot. real Gold boot.
### 5. `mod.card` ### 5. What a mod's code can reach
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:
| Absent | Use instead |
| --- | --- |
| `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) and `mod:read` |
| `love.thread`, `love.system`, `love.event` | `mod.events`, `mod.hooks` |
The rest of `love` passes through unchanged, so graphics, audio, timers and
input work as they always have.
Three consequences worth knowing before you write against it:
- **Your globals are yours.** `_G` inside a mod is that mod's own table. Two
mods no longer share a namespace, and neither can reach the engine's. To
publish something to another mod, put it on `mod.exports` and let them
`mod.find("your_id").exports` — the channel that was always the intended
one. The same goes for the standard library: `string`, `table` and `math`
are per-mod copies, so patching one is a local decision.
- **Paths cannot climb.** `mod:read`, `mod.assets:path` and `mod.assets:image`
join to your own directory, and `..`, absolute paths and drive letters are
refused. So are `entry` and `options_schema` in your manifest.
- **Ship source, not bytecode.** A precompiled entry file is refused.
`permissions` in the manifest is still a disclosure the manager shows the
player, and `network` now gates `require("socket")` and friends. There is no
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.
### 6. `mod.card`
The manifest is the *engine's* contract: identity, load order, dependencies, The manifest is the *engine's* contract: identity, load order, dependencies,
permissions, profile. The card is the *human-facing* one: who made this, permissions, profile. The card is the *human-facing* one: who made this,
@@ -263,7 +303,7 @@ Two fields deserve their own note:
distributed mod never carries ROM-derived bytes, not even in its preview distributed mod never carries ROM-derived bytes, not even in its preview
images. images.
### 6. Tags ### 7. Tags
Lowercase kebab strings, open vocabulary. The showcase generator Lowercase kebab strings, open vocabulary. The showcase generator
lowercases and de-dupes. A recommended starting set: `beginner`, lowercases and de-dupes. A recommended starting set: `beginner`,
+1
View File
@@ -19,6 +19,7 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow
* **Soft reset button combination** * **Soft reset button combination**
* **Keyboard and controller rebinding** * **Keyboard and controller rebinding**
* **Mod profiles** with separate mod settings and save slots * **Mod profiles** with separate mod settings and save slots
* **Sandboxed mods**: an installed mod can read only its own folder and write only its own storage, so it cannot reach the rest of your device
* **Improved launcher and save editor UI**, including background downloads and update checks * **Improved launcher and save editor UI**, including background downloads and update checks
* **Direct-launch options** for shortcuts, Steam entries, and handheld frontends * **Direct-launch options** for shortcuts, Steam entries, and handheld frontends
* **Custom boot branding** * **Custom boot branding**
+100
View File
@@ -0,0 +1,100 @@
Discord announcement for mod authors: the mod sandbox.
Paste each block below as its own message (Discord caps a message at 2000
characters). Delete the ===== MESSAGE N ===== separator lines first.
Discord does not render markdown tables, so the lists below are code blocks.
===== MESSAGE 1 =====
# Heads up for mod authors: mods now run in a sandbox
**Short version:** if your mod uses `io`, `os.getenv`, `os.execute` or `love.filesystem`, it will stop loading in the next release. There is a replacement for every legitimate use, and it is usually one line.
## What changed
Until now, a mod's Lua code ran against the engine's own globals. That meant any installed mod had full read and write access to the player's entire filesystem: their documents, their photos, anything their user account could touch. The `permissions` field in `manifest.json` did not actually enforce anything.
That was our design mistake, not yours. If your mod used `io`, it was using the API exactly as it was handed to you.
Starting with the next release, mod code runs in a restricted environment. A mod can read its own folder and write its own storage. It cannot name a path outside the game, in any spelling.
===== MESSAGE 2 =====
## What is no longer available
```
GONE USE INSTEAD
io.* and require("io") mod:read("file") for your own files
mod.storage to persist data
os.getenv / execute / remove nothing. os.time, os.date and
/ rename / exit os.clock still work
package, dofile, loadfile, debug, require still resolves the
getfenv, setfenv supported engine modules
require("ffi") the love table you already have
require("love.filesystem") the love table you already have
love.filesystem mod.storage (scoped per mod and
per playthrough) and mod:read
love.thread, love.system, love.event mod.events, mod.hooks
```
Everything else in `love` is unchanged: graphics, audio, timers and input all work exactly as before. So does the whole standard library apart from the lines above.
===== MESSAGE 3 =====
## Three smaller changes in the same release
**1. Your globals are your own.** `_G` inside a mod is now that mod's private table. If you were passing data to another mod through a global, put it on `mod.exports` and let them read it with `mod.find("your_id").exports`. That was always the intended channel, and unlike a global it survives load order and tells you the other mod's version.
**2. Paths cannot climb out of your folder.** `mod:read`, `mod.assets:path` and `mod.assets:image` refuse `..`, absolute paths and drive letters, as do `entry` and `options_schema` in your manifest.
**3. Ship source, not bytecode.** A precompiled `main.lua` is refused.
===== MESSAGE 4 =====
## Check your mod in one command
```sh
grep -rnE '\bio\.|os\.(getenv|execute|remove|rename|exit|tmpname)|love\.(filesystem|thread|system|event)|require\("(io|os|debug|package|ffi|love\.)' your_mod/
```
No output means you are fine and nothing about your mod changes.
If you do get hits, the load-time errors name the replacement directly, so you can also just run your mod and read what it tells you:
```sh
python3 tools/modkit.py validate mods/your_mod --base imported
```
===== MESSAGE 5 =====
## The most common case
Nearly every real use of `io` in a mod was "save some state of my own", and that is what `mod.storage` is for. It is data-only, already scoped to your mod and the current playthrough, and it survives the player switching profiles.
```lua
-- before
local f = io.open("my_settings.txt", "w")
f:write(value)
f:close()
-- after
mod.storage:write(game, "settings", { value = value })
local saved = mod.storage:read(game, "settings")
```
===== MESSAGE 6 =====
## About `permissions`
There is no permission that grants raw filesystem access, and there will not be one. Nothing a mod legitimately does needs it, and a permission that can be requested is a permission that gets requested. `permissions` is still shown to the player in the mod manager, and `network` now genuinely gates the networking modules.
## If you have a case this does not cover
Please open an issue rather than working around it. If there is a real thing mods need to do that the sandbox blocks, that is a gap in the API and we want to fix it properly, with a scoped engine call rather than a hole.
Thanks for building for this project, and sorry for the churn. This one was worth the disruption: it means a player installing your mod is trusting it with the game, and not with their whole machine.
+1
View File
@@ -0,0 +1 @@
/Users/bryanbassett/Documents/development/pokemon-gen1-recomp-project/.bazinga/mods/timekeepers_hut
+3 -19
View File
@@ -16,6 +16,7 @@
local Logger = require("src.core.Logger") local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local SafePath = require("src.mods.SafePath")
local unpack = table.unpack or unpack local unpack = table.unpack or unpack
local loadstring = loadstring or load local loadstring = loadstring or load
@@ -31,25 +32,8 @@ AssetTransform.SOURCE_ROOT = SOURCE_ROOT
AssetTransform.DERIVED_ROOT = DERIVED_ROOT AssetTransform.DERIVED_ROOT = DERIVED_ROOT
-- ------- path sandbox -- ------- path sandbox
-- shared with mod:read and the manifest's own paths (src/mods/SafePath.lua)
-- a relative path that cannot climb out of the root it is joined to local requireRelative = SafePath.require
local function safeRelative(rel)
if type(rel) ~= "string" or rel == "" then return nil end
if rel:sub(1, 1) == "/" then return nil end
if rel:find("\\", 1, true) then return nil end
for segment in rel:gmatch("[^/]+") do
if segment == ".." or segment == "." then return nil end
end
return rel
end
local function requireRelative(rel, what)
local safe = safeRelative(rel)
if not safe then
error(("%s must stay inside its root, got %q"):format(what, tostring(rel)), 0)
end
return safe
end
-- ------- the restricted context -- ------- the restricted context
+58 -23
View File
@@ -12,6 +12,8 @@ local Manifest = require("src.mods.Manifest")
local Merge = require("src.mods.Merge") local Merge = require("src.mods.Merge")
local ModTargets = require("src.mods.ModTargets") local ModTargets = require("src.mods.ModTargets")
local Registry = require("src.mods.Registry") local Registry = require("src.mods.Registry")
local SafePath = require("src.mods.SafePath")
local Sandbox = require("src.mods.Sandbox")
local Schemas = require("src.mods.Schemas") local Schemas = require("src.mods.Schemas")
local Semver = require("src.mods.Semver") local Semver = require("src.mods.Semver")
local Events = require("src.mods.Events") local Events = require("src.mods.Events")
@@ -72,10 +74,12 @@ local function orderedIds(mods, filter)
return ids return ids
end end
-- ------- dev-mode permissions tripwire -- ------- the require gate
-- Attribution only: the shim delegates unconditionally and blocks nothing. -- Two jobs in one interposition. The engine_internals/network scan is
-- Installed once per process and only when the loader runs in dev mode, so a -- attribution only and stays dev-mode: it warns and delegates. The
-- player build has zero interposition. -- Sandbox.moduleDenial check is not -- require("io") would hand back
-- package.loaded.io and undo the whole mod environment -- so it is installed
-- in player builds too, for any boot that has mods on it.
local devShim = { installed = false, permissions = {}, warned = {}, depth = 0 } local devShim = { installed = false, permissions = {}, warned = {}, depth = 0 }
@@ -135,7 +139,8 @@ end
local function scanRequire(name) local function scanRequire(name)
local modId = Runtime.currentMod local modId = Runtime.currentMod
if not modId or type(name) ~= "string" then return end if type(modId) ~= "string" then modId = Runtime.modRequire end
if type(modId) ~= "string" or type(name) ~= "string" then return end
local granted = devShim.permissions[modId] or {} local granted = devShim.permissions[modId] or {}
local function warnOnce(permission) local function warnOnce(permission)
local key = modId .. "|" .. permission .. "|" .. name local key = modId .. "|" .. permission .. "|" .. name
@@ -188,6 +193,7 @@ function Loader:_installDevShim()
for id, mod in pairs(self.mods) do for id, mod in pairs(self.mods) do
devShim.permissions[id] = mod.manifest.permissionSet devShim.permissions[id] = mod.manifest.permissionSet
end end
devShim.dev = self.dev
if devShim.installed then return end if devShim.installed then return end
devShim.installed = true devShim.installed = true
local delegate = require local delegate = require
@@ -195,12 +201,22 @@ function Loader:_installDevShim()
-- only the mod's own call is the mod's doing; whatever that module -- only the mod's own call is the mod's doing; whatever that module
-- requires in turn is the engine wiring itself up -- requires in turn is the engine wiring itself up
if devShim.depth == 0 then if devShim.depth == 0 then
scanRequire(name) -- Backstop for the deny list Sandbox.envFor's require already applies:
-- an engine module requiring io is the engine wiring itself up, a mod
-- doing it is the hole this closes, and any future path that runs mod
-- code without a sandbox env still lands here.
local owner = Runtime.currentMod or Runtime.modRequire
if owner or callerIsMod(3) then
local id = type(owner) == "string" and owner or nil
local denial = Sandbox.moduleDenial(name, devShim.permissions[id])
if denial then error(("[%s] %s"):format(id or "mod", denial), 0) end
end
if devShim.dev or devShim.generation ~= 1 then scanRequire(name) end
-- The Gen 1 name a mod asked for, answered by the Gen 2 arm behind it. -- The Gen 1 name a mod asked for, answered by the Gen 2 arm behind it.
-- Engine code keeps the real module: src/render/PaletteFX.lua:776 -- Engine code keeps the real module: src/render/PaletteFX.lua:776
-- requires src.core.Game on both generations and means it. -- requires src.core.Game on both generations and means it.
if devShim.generation ~= 1 and Gen2Compat.serves(name) if devShim.generation ~= 1 and Gen2Compat.serves(name)
and callerIsMod(3) then and (owner or callerIsMod(3)) then
local adapter = Gen2Compat.resolve(name, Runtime.currentMod) local adapter = Gen2Compat.resolve(name, Runtime.currentMod)
if adapter then if adapter then
local key = "adapter|" .. name local key = "adapter|" .. name
@@ -235,7 +251,7 @@ function Loader.new(opts)
events = Events.new(), hooks = Hooks.new(), content = {}, assets = {}, events = Events.new(), hooks = Hooks.new(), content = {}, assets = {},
exports = {}, migrations = {}, order = {}, exports = {}, migrations = {}, order = {},
modSave = {}, modOptions = {}, optionSchemas = {}, imageCache = {}, modSave = {}, modOptions = {}, optionSchemas = {}, imageCache = {},
modInput = {}, modInput = {}, modEnv = {},
fs = (opts and opts.fs) or (love and love.filesystem), fs = (opts and opts.fs) or (love and love.filesystem),
dev = dev, dev = dev,
-- Which generation this boot is (1 or 2). Fixed at construction: the -- Which generation this boot is (1 or 2). Fixed at construction: the
@@ -358,11 +374,13 @@ function Loader:_writeOptionSchemas()
-- demand; using it here means older mods do not need to migrate to -- demand; using it here means older mods do not need to migrate to
-- mod.options:define just to appear in a launcher settings screen. -- mod.options:define just to appear in a launcher settings screen.
if schema == nil and mod.manifest.options_schema and self.fs.load then if schema == nil and mod.manifest.options_schema and self.fs.load then
local chunk = self.fs.load(mod.path .. "/" .. mod.manifest.options_schema) local ok, rows = pcall(function()
if chunk then local path = SafePath.join(mod.path, mod.manifest.options_schema,
local ok, rows = pcall(chunk) "options_schema")
if ok and type(rows) == "table" then schema = rows end local chunk = Sandbox.loadFile(self.fs, path, self:_modEnv(mod))
end return chunk and chunk()
end)
if ok and type(rows) == "table" then schema = rows end
end end
if schema ~= nil then if schema ~= nil then
mods[id] = schema mods[id] = schema
@@ -1091,9 +1109,11 @@ function Loader:_api(mod)
-- assets keeps the v1 alias to the content accessors and adds the file -- assets keeps the v1 alias to the content accessors and adds the file
-- helpers on top, so mod.assets.pokemon and mod.assets:image both resolve -- helpers on top, so mod.assets.pokemon and mod.assets:image both resolve
api.assets = setmetatable({ api.assets = setmetatable({
path = function(_, relative) return mod.path .. "/" .. relative end, path = function(_, relative)
return SafePath.join(mod.path, relative, "mod.assets:path")
end,
image = function(_, relative) image = function(_, relative)
local full = mod.path .. "/" .. relative local full = SafePath.join(mod.path, relative, "mod.assets:image")
local cached = loader.imageCache[full] local cached = loader.imageCache[full]
if cached then return cached end if cached then return cached end
assert(love and love.graphics, assert(love and love.graphics,
@@ -1103,9 +1123,10 @@ function Loader:_api(mod)
return image return image
end, end,
}, { __index = api.content }) }, { __index = api.content })
-- the mod's own directory and nothing above it: PhysFS already refuses a
-- climb, but loader.fs is injectable and has no such floor
function api:read(relative) function api:read(relative)
local path = self.path .. "/" .. relative return loader.fs.read(SafePath.join(self.path, relative, "mod:read"))
return loader.fs.read(path)
end end
-- mod.world materializes on first touch, like the image helper above: a -- mod.world materializes on first touch, like the image helper above: a
-- headless load must not drag the world stack in, and the Game the facade -- headless load must not drag the world stack in, and the Game the facade
@@ -1149,9 +1170,21 @@ function Loader:_game()
return engineRequire("src.core.Game") return engineRequire("src.core.Game")
end end
-- The environment every chunk this mod authors runs in, built once per mod so
-- its entry file and its options_schema share one globals table.
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 })
self.modEnv[id] = env
end
return env
end
function Loader:_loadMod(mod) function Loader:_loadMod(mod)
local path = mod.path .. "/" .. mod.manifest.entry local path = SafePath.join(mod.path, mod.manifest.entry, "manifest entry")
local chunk, err = self.fs.load(path) local chunk, err = Sandbox.loadFile(self.fs, path, self:_modEnv(mod))
if not chunk then error(err or ("unable to load " .. path)) end if not chunk then error(err or ("unable to load " .. path)) end
local api = self:_api(mod) local api = self:_api(mod)
local result = chunk(api) local result = chunk(api)
@@ -1349,10 +1382,12 @@ function Loader:load(data)
-- every touch: a mod captures the facade at file scope, before Game2 has a -- every touch: a mod captures the facade at file scope, before Game2 has a
-- save or a world (src/mods/Gen2Compat.lua). -- save or a world (src/mods/Gen2Compat.lua).
Gen2Compat.bind(function() return self:_game() end) Gen2Compat.bind(function() return self:_game() end)
-- Dev mode wants the permissions tripwire; a Gold boot with mods on it wants -- Any boot with mods on it needs the gate, because require("io") is how a
-- the Gen 1-only require report, which is the difference between "the mod -- mod would walk out of Sandbox.envFor. Dev mode adds the permissions
-- does nothing" and knowing why. A Gold boot with no mods pays nothing. -- tripwire on top, and a Gold boot the Gen 1-only require report -- the
if self.dev or (self.generation ~= 1 and next(self.mods) ~= nil) then -- difference between "the mod does nothing" and knowing why. A boot with no
-- mods pays nothing.
if self.dev or next(self.mods) ~= nil then
self:_installDevShim() self:_installDevShim()
end end
for _, mod in ipairs(ordered) do for _, mod in ipairs(ordered) do
+14 -7
View File
@@ -8,6 +8,8 @@ local Font = require("src.render.Font")
local GameVersion = require("src.core.GameVersion") local GameVersion = require("src.core.GameVersion")
local ModTargets = require("src.mods.ModTargets") local ModTargets = require("src.mods.ModTargets")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local SafePath = require("src.mods.SafePath")
local Sandbox = require("src.mods.Sandbox")
local SaveData = require("src.core.SaveData") local SaveData = require("src.core.SaveData")
local Semver = require("src.mods.Semver") local Semver = require("src.mods.Semver")
local Version = require("src.core.Version") local Version = require("src.core.Version")
@@ -936,13 +938,18 @@ function ManagerState:schemaFor(m)
local schema = loader.optionSchemas and loader.optionSchemas[m.id] local schema = loader.optionSchemas and loader.optionSchemas[m.id]
if schema == nil and m.options_schema and m.path if schema == nil and m.options_schema and m.path
and loader.fs and loader.fs.load then and loader.fs and loader.fs.load then
local chunk = loader.fs.load(m.path .. "/" .. m.options_schema) -- mod-authored code, so it runs in the same sandbox the entry chunk does
if chunk then local ok, rows = pcall(function()
local ok, rows = pcall(chunk) local path = SafePath.join(m.path, m.options_schema, "options_schema")
if ok and type(rows) == "table" then local mod = loader.mods and loader.mods[m.id]
schema = rows local env = mod and loader._modEnv and loader:_modEnv(mod)
if loader.optionSchemas then loader.optionSchemas[m.id] = schema end or Sandbox.envFor()
end local chunk = Sandbox.loadFile(loader.fs, path, env)
return chunk and chunk()
end)
if ok and type(rows) == "table" then
schema = rows
if loader.optionSchemas then loader.optionSchemas[m.id] = schema end
end end
end end
return schema return schema
+13 -3
View File
@@ -3,6 +3,7 @@
-- that need to stat a file, this owns shape, vocabulary and range grammar. -- that need to stat a file, this owns shape, vocabulary and range grammar.
local Logger = require("src.core.Logger") local Logger = require("src.core.Logger")
local ModTargets = require("src.mods.ModTargets") local ModTargets = require("src.mods.ModTargets")
local SafePath = require("src.mods.SafePath")
local Semver = require("src.mods.Semver") local Semver = require("src.mods.Semver")
local Version = require("src.core.Version") local Version = require("src.core.Version")
@@ -144,6 +145,9 @@ function Manifest.validate(raw, path)
assert(type(raw.name) == "string" and raw.name ~= "", "manifest name is required") assert(type(raw.name) == "string" and raw.name ~= "", "manifest name is required")
assert(type(raw.version) == "string" and raw.version ~= "", "manifest version is required") assert(type(raw.version) == "string" and raw.version ~= "", "manifest version is required")
assert(type(raw.entry) == "string" and raw.entry ~= "", "manifest entry is required") assert(type(raw.entry) == "string" and raw.entry ~= "", "manifest entry is required")
-- every manifest path is joined to the mod's own directory, so none of them
-- may climb out of it (src/mods/SafePath.lua)
local entry = SafePath.require(raw.entry, "manifest entry")
-- absent means 1: full v1 compat, schema violations downgrade to warnings -- absent means 1: full v1 compat, schema violations downgrade to warnings
assert(raw.api == nil or tonumber(raw.api) ~= nil, "manifest api must be a number") assert(raw.api == nil or tonumber(raw.api) ~= nil, "manifest api must be a number")
@@ -229,19 +233,24 @@ function Manifest.validate(raw, path)
local affectsLink = profile ~= "content" and not language local affectsLink = profile ~= "content" and not language
if type(raw.affects_link) == "boolean" then affectsLink = raw.affects_link end if type(raw.affects_link) == "boolean" then affectsLink = raw.affects_link end
local function optionalFile(value, field) local function optionalString(value, field)
if value == nil then return nil end if value == nil then return nil end
assert(type(value) == "string" and value ~= "", field .. " must be a file path") assert(type(value) == "string" and value ~= "", field .. " must be a file path")
return value return value
end end
local function optionalFile(value, field)
local text = optionalString(value, field)
return text and SafePath.require(text, field)
end
local conflicts = mergeConflictLists(raw.conflicts, raw.incompatible) local conflicts = mergeConflictLists(raw.conflicts, raw.incompatible)
return { return {
id = raw.id, id = raw.id,
name = raw.name, name = raw.name,
version = raw.version, version = raw.version,
entry = raw.entry, entry = entry,
api = api, api = api,
priority = tonumber(raw.priority) or 0, priority = tonumber(raw.priority) or 0,
dependencies = array(raw.dependencies), dependencies = array(raw.dependencies),
@@ -265,7 +274,8 @@ function Manifest.validate(raw, path)
permissionSet = permissionSet, permissionSet = permissionSet,
options_schema = optionalFile(raw.options_schema, "options_schema"), options_schema = optionalFile(raw.options_schema, "options_schema"),
assets_transforms = optionalFile(raw.assets_transforms, "assets_transforms"), assets_transforms = optionalFile(raw.assets_transforms, "assets_transforms"),
force_enable_env = optionalFile(raw.force_enable_env, "force_enable_env"), -- an env var name, not a path, so it keeps the plain string check
force_enable_env = optionalString(raw.force_enable_env, "force_enable_env"),
path = path, path = path,
raw = raw, raw = raw,
} }
+5
View File
@@ -28,6 +28,11 @@ Runtime.errors = nil
-- permissions tripwire knows there is nobody to attribute to -- permissions tripwire knows there is nobody to attribute to
Runtime.currentMod = nil Runtime.currentMod = nil
-- set by the sandbox's require for the duration of one mod-initiated require,
-- so the loader's gate can still attribute a lazy one made long after
-- currentMod went back to nil (src/mods/Sandbox.lua)
Runtime.modRequire = nil
function Runtime.install(events, hooks, errors) function Runtime.install(events, hooks, errors)
Runtime.events, Runtime.hooks = events, hooks Runtime.events, Runtime.hooks = events, hooks
Runtime.errors = errors Runtime.errors = errors
+38
View File
@@ -0,0 +1,38 @@
-- One relative-path grammar for every path a mod supplies: a mod names files
-- inside its own directory and nowhere else. love.filesystem (PhysFS) already
-- refuses "..", absolute paths and backslashes, but Loader.new takes an
-- injected fs that has no such floor, so the rule lives here and not in
-- whichever filesystem happens to be underneath.
local SafePath = {}
-- The normalized path, or nil when it could climb out of the root it is about
-- to be joined to. "." segments are dropped rather than rejected so a
-- manifest that says "./main.lua" still loads.
function SafePath.safe(rel)
if type(rel) ~= "string" or rel == "" then return nil end
if rel:sub(1, 1) == "/" then return nil end
if rel:find("\\", 1, true) then return nil end
if rel:match("^%a:") then return nil end -- windows drive-relative
local parts = {}
for segment in rel:gmatch("[^/]+") do
if segment == ".." then return nil end
if segment ~= "." then parts[#parts + 1] = segment end
end
if #parts == 0 then return nil end
return table.concat(parts, "/")
end
function SafePath.require(rel, what)
local safe = SafePath.safe(rel)
if not safe then
error(("%s must stay inside its root, got %q"):format(what, tostring(rel)), 0)
end
return safe
end
-- root .. "/" .. rel, with the traversal check in between
function SafePath.join(root, rel, what)
return root .. "/" .. SafePath.require(rel, what or "path")
end
return SafePath
+227
View File
@@ -0,0 +1,227 @@
-- The environment a mod's own code runs in. Every chunk a mod authors -- the
-- entry file, an options_schema, anything it hands to load() -- runs against
-- this table instead of _G, so the only paths it can name are the ones the
-- engine hands it (mod:read, mod.storage, mod.assets).
--
-- What this is and is not: raw io/os/ffi are the only way to name a file
-- outside the game tree at all, and they are absent here, so the reported
-- "any mod can rewrite anything in your home directory" hole closes by
-- construction. Inside the LÖVE tree this is defense in depth, not a security
-- boundary: an engine module reached through require, or ImageData:encode,
-- still writes in the save directory.
--
-- Lua 5.1/LuaJIT is the target, so setfenv is the mechanism; the 5.2+ arm
-- exists because AssetTransform's sandbox needed it and getting this wrong
-- silently hands the chunk the real globals.
local Runtime = require("src.mods.Runtime")
local SafePath = require("src.mods.SafePath")
local Sandbox = {}
-- Modules that hand a mod the disk, a raw socket or a fresh Lua state no
-- matter what this file removes from the environment. package.loaded.io is
-- the one call that would undo every other rule here.
local DENIED = {
io = "the filesystem", os = "the filesystem", debug = "the debug library",
package = "the module loader", ffi = "arbitrary C calls",
}
-- Same idea one level up: love.filesystem is reachable by name, and
-- love.thread starts a Lua state this sandbox has no say over.
local DENIED_PREFIX = { ["love"] = true, ["ffi"] = true }
-- The wire, which is what the network permission governs.
local NETWORK = { socket = true, enet = true, http = true, https = true,
ssl = true, mime = true, ltn12 = true }
local function head(name)
return (name:match("^([^%.]+)")) or name
end
-- nil when the require is allowed, else the message to fail it with.
function Sandbox.moduleDenial(name, permissionSet)
if type(name) ~= "string" then return nil end
local root = head(name)
local reason = DENIED[root]
if reason then
return ("%s is not available to mods (it grants %s); use mod.storage, "
.. "mod:read and the engine API instead"):format(name, reason)
end
if DENIED_PREFIX[root] and name ~= root then
return ("%s is not available to mods; use mod.storage, mod:read and the "
.. "engine API instead"):format(name)
end
if NETWORK[root] and not (permissionSet or {}).network then
return ("%s needs the \"network\" permission in manifest.json"):format(name)
end
return nil
end
-- ------- the love facade
-- Dropped, not narrowed: filesystem writes anywhere in the save directory
-- (including another mod's storage), thread opens a Lua state with a full
-- standard library, system.openURL launches whatever it is handed, and event
-- lets a mod quit the game out from under the player. Everything else LÖVE
-- exposes passes through, so a new module in a future LÖVE is available
-- without an edit here.
-- value is the replacement to name in the error, or true when there is none
local BLOCKED_LOVE = {
filesystem = "mod.storage and mod:read", thread = true,
system = true, event = true,
}
local loveProxy
local function loveFacade()
if loveProxy or not _G.love then return loveProxy end
loveProxy = setmetatable({}, {
__index = function(_, key)
local hint = BLOCKED_LOVE[key]
if hint then
error(("love.%s is not available to mods%s"):format(key,
type(hint) == "string" and (", use " .. hint) or ""), 2)
end
return _G.love[key]
end,
__newindex = function(_, key)
error(("mods cannot assign love.%s"):format(tostring(key)), 2)
end,
})
return loveProxy
end
-- ------- the environment
-- Absent on purpose: io, package, dofile, loadfile, getfenv, setfenv, debug,
-- newproxy, module. os keeps only the clock -- getenv is how the reported
-- exploit found the user's home directory.
local SAFE_OS = { time = true, date = true, clock = true, difftime = true }
-- Per-mod copies, not the shared tables: a mod that assigns string.trim or
-- replaces table.insert changes its own view and nobody else's. The functions
-- are the same objects, so state behind them (math.randomseed's RNG) is
-- unaffected -- only the namespace is private.
local function copy(source)
if type(source) ~= "table" then return source end
local out = {}
for key, value in pairs(source) do out[key] = value end
return out
end
local function baseGlobals()
local safeOs = {}
for key in pairs(SAFE_OS) do safeOs[key] = os[key] end
return {
assert = assert, error = error, ipairs = ipairs, next = next,
pairs = pairs, pcall = pcall, xpcall = xpcall, select = select,
tonumber = tonumber, tostring = tostring, type = type, unpack = unpack,
rawequal = rawequal, rawget = rawget, rawset = rawset, rawlen = rawlen,
setmetatable = setmetatable, getmetatable = getmetatable, print = print,
collectgarbage = collectgarbage, _VERSION = _VERSION,
coroutine = copy(coroutine), math = copy(math), string = copy(string),
table = copy(table), bit = copy(bit), jit = jit, os = safeOs,
}
end
-- setfenv on 5.1/LuaJIT; on 5.2+ the env has to be handed to load itself, so
-- a caller there compiles through Sandbox.compile instead.
function Sandbox.bind(chunk, env)
if setfenv then setfenv(chunk, env) end
return chunk
end
-- Bytecode is unreviewable and, on LuaJIT, a way out of any sandbox built out
-- of environments. Mods ship source.
local function rejectBytecode(source, what)
if type(source) == "string" and source:sub(1, 1) == "\27" then
return nil, (what or "chunk") .. ": mods must ship Lua source, not bytecode"
end
return true
end
function Sandbox.compile(source, chunkname, env)
local ok, err = rejectBytecode(source, chunkname)
if not ok then return nil, err end
if setfenv then
local chunk, compileErr = loadstring(source, chunkname)
if not chunk then return nil, compileErr end
return setfenv(chunk, env)
end
return load(source, chunkname, "t", env)
end
-- The load() a mod sees. Lua 5.1 gives a loaded chunk the GLOBAL environment
-- rather than the caller's, so without this every sandboxed mod is one
-- load(mod:read(...)) away from the real _G -- which is exactly how the
-- multi-file mods in mods/ are written.
local function sandboxedLoad(env)
return function(chunk, chunkname)
if type(chunk) == "function" then
local parts = {}
while true do
local piece = chunk()
if piece == nil or piece == "" then break end
parts[#parts + 1] = piece
end
chunk = table.concat(parts)
end
if type(chunk) ~= "string" then return nil, "load expects a string or reader" end
return Sandbox.compile(chunk, chunkname or "=(load)", env)
end
end
-- The require a mod sees: the deny list lives here rather than on a stack
-- walk, because pcall(require, "io") puts a C frame where the walk would look.
-- 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)
return function(name, ...)
local denial = Sandbox.moduleDenial(name, permissionSet)
if denial then error(("[%s] %s"):format(modId or "mod", denial), 2) end
local previous = Runtime.modRequire
Runtime.modRequire = modId or true
local ok, result = pcall(_G.require, name, ...)
Runtime.modRequire = previous
if not ok then error(result, 0) end
return result
end
end
function Sandbox.envFor(opts)
opts = opts or {}
local env = baseGlobals()
env.love = loveFacade()
env.require = sandboxedRequire(opts.modId, opts.permissions)
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
return env
end
-- fs.load keeps the real filesystem's handling of the file; the environment is
-- swapped after the fact. The 5.2+ arm has to go back to source, which is the
-- only reason fs.read is touched here.
function Sandbox.loadFile(fs, path, env)
if fs.read then
local ok, err = rejectBytecode(fs.read(path), path)
if not ok then return nil, err end
end
if setfenv then
local chunk, err = fs.load(path)
if not chunk then return nil, err end
return setfenv(chunk, env)
end
local source = fs.read and fs.read(path)
if not source then return nil, "unable to read " .. path end
return Sandbox.compile(source, "@" .. path, env)
end
Sandbox.safePath = SafePath.safe
Sandbox.requirePath = SafePath.require
return Sandbox
@@ -72,10 +72,11 @@ local function writeProbe()
fs.write("mods/cold_start_probe/manifest.json", fs.write("mods/cold_start_probe/manifest.json",
'{"id":"cold_start_probe","name":"cold start probe","version":"1.0.0",' '{"id":"cold_start_probe","name":"cold start probe","version":"1.0.0",'
.. '"entry":"main.lua","api":2,"profile":"content"}') .. '"entry":"main.lua","api":2,"profile":"content"}')
-- mod.exports, not _G: a mod's globals are its own (src/mods/Sandbox.lua)
fs.write("mods/cold_start_probe/main.lua", [[ fs.write("mods/cold_start_probe/main.lua", [[
return function(mod) return function(mod)
_G.COLD_STORAGE = mod.storage mod.exports.storage = mod.storage
_G.COLD_CHECKPOINTS = mod.checkpoints mod.exports.checkpoints = mod.checkpoints
end end
]]) ]])
end end
@@ -125,11 +126,12 @@ if phase == "capture" then
local game = runtime(SaveData.newGame({ version = "red" }), false) local game = runtime(SaveData.newGame({ version = "red" }), false)
loader.game = game loader.game = game
assert(loader:load({}) == true) assert(loader:load({}) == true)
assert(_G.COLD_STORAGE:write(game, "history/index", { newest = "q0001" })) local probe = assert(loader.exports.cold_start_probe)
local checkpoint = assert(_G.COLD_CHECKPOINTS:capture(game)) assert(probe.storage:write(game, "history/index", { newest = "q0001" }))
assert(_G.COLD_STORAGE:write(game, "history/q0001", checkpoint)) local checkpoint = assert(probe.checkpoints:capture(game))
assert(probe.storage:write(game, "history/q0001", checkpoint))
local id = assert(game.save.meta.playthroughId) local id = assert(game.save.meta.playthroughId)
assert(_G.COLD_CHECKPOINTS:ensureNormalSave(game, checkpoint)) assert(probe.checkpoints:ensureNormalSave(game, checkpoint))
local normal = assert(SaveData.load("red")) local normal = assert(SaveData.load("red"))
assert(normal.meta.playthroughId == id) assert(normal.meta.playthroughId == id)
fs.write("cold-start-witness.lua", SaveSerializer.encode({ playthroughId = id })) fs.write("cold-start-witness.lua", SaveSerializer.encode({ playthroughId = id }))
@@ -139,15 +141,16 @@ else
title.save.options = { volume = 7, bindings = {} } title.save.options = { volume = 7, bindings = {} }
loader.game = title loader.game = title
assert(loader:load({}) == true) assert(loader:load({}) == true)
local selected = assert(_G.COLD_STORAGE:selected(title)) local probe = assert(loader.exports.cold_start_probe)
local selected = assert(probe.storage:selected(title))
local witness = assert(SaveSerializer.decode(assert(fs.read("cold-start-witness.lua")))) local witness = assert(SaveSerializer.decode(assert(fs.read("cold-start-witness.lua"))))
assert(selected:context().playthroughId == witness.playthroughId) assert(selected:context().playthroughId == witness.playthroughId)
assert(selected:read("history/index").newest == "q0001") assert(selected:read("history/index").newest == "q0001")
local checkpoint = assert(selected:read("history/q0001")) local checkpoint = assert(selected:read("history/q0001"))
assert(_G.COLD_CHECKPOINTS:resume(title, checkpoint)) assert(probe.checkpoints:resume(title, checkpoint))
assert(title.save.meta.playthroughId == witness.playthroughId) assert(title.save.meta.playthroughId == witness.playthroughId)
assert(title.save.options.volume == 7) assert(title.save.options.volume == 7)
assert(SaveSerializer.encode(_G.COLD_CHECKPOINTS:capture(title)) assert(SaveSerializer.encode(probe.checkpoints:capture(title))
== SaveSerializer.encode(checkpoint)) == SaveSerializer.encode(checkpoint))
local normal = assert(SaveData.load("red")) local normal = assert(SaveData.load("red"))
assert(normal.meta.playthroughId == witness.playthroughId) assert(normal.meta.playthroughId == witness.playthroughId)
+4 -6
View File
@@ -102,20 +102,19 @@ if not headlessOk then error(headlessErr) end
love = love or require("tests.love_stub") love = love or require("tests.love_stub")
-- ------- discovery, dependency order, merge -- ------- discovery, dependency order, merge
-- "addon" sorts before "base" so only the dependency edge can order them -- "addon" sorts before "base" so only the dependency edge can order them.
_G.MOD_TEST_ORDER = {} -- loader.order is the engine's own record of what ran when; a mod cannot
-- append to a shared global any more (src/mods/Sandbox.lua).
local files = { local files = {
["mods/addon/manifest.json"] = manifestJson("addon", '["base"]'), ["mods/addon/manifest.json"] = manifestJson("addon", '["base"]'),
["mods/addon/main.lua"] = [[ ["mods/addon/main.lua"] = [[
return function(mod) return function(mod)
_G.MOD_TEST_ORDER[#_G.MOD_TEST_ORDER + 1] = "addon"
mod.content.pokemon:override("MODMON", { name = "ADDONMON" }) mod.content.pokemon:override("MODMON", { name = "ADDONMON" })
end end
]], ]],
["mods/base/manifest.json"] = manifestJson("base"), ["mods/base/manifest.json"] = manifestJson("base"),
["mods/base/main.lua"] = [[ ["mods/base/main.lua"] = [[
return function(mod) return function(mod)
_G.MOD_TEST_ORDER[#_G.MOD_TEST_ORDER + 1] = "base"
mod.content.pokemon:register("MODMON", { name = "BASEMON" }) mod.content.pokemon:register("MODMON", { name = "BASEMON" })
mod.content.music:register("MOD_SONG", { file = "song.ogg" }) mod.content.music:register("MOD_SONG", { file = "song.ogg" })
end end
@@ -126,7 +125,7 @@ local loader = Loader.new({ fs = memfs(files) })
check(loader:load(data) == true, "headless load succeeds with injected fs") check(loader:load(data) == true, "headless load succeeds with injected fs")
check(loader.mods.addon ~= nil and loader.mods.base ~= nil, check(loader.mods.addon ~= nil and loader.mods.base ~= nil,
"discovery finds both mods") "discovery finds both mods")
check(_G.MOD_TEST_ORDER[1] == "base" and _G.MOD_TEST_ORDER[2] == "addon", check(loader.order[1] == "base" and loader.order[2] == "addon",
"topo-sort runs the dependency before its dependent") "topo-sort runs the dependency before its dependent")
check(data.pokemon.MODMON ~= nil and data.pokemon.MODMON.name == "ADDONMON", check(data.pokemon.MODMON ~= nil and data.pokemon.MODMON.name == "ADDONMON",
"registered content merges into data") "registered content merges into data")
@@ -411,6 +410,5 @@ local StateStack = require("src.core.StateStack")
while StateStack:top() do StateStack:pop() end while StateStack:top() do StateStack:pop() end
require("src.core.Music").stop() require("src.core.Music").stop()
Runtime.install(savedEvents, savedHooks) Runtime.install(savedEvents, savedHooks)
_G.MOD_TEST_ORDER = nil
S.finish() S.finish()
+6 -8
View File
@@ -341,7 +341,8 @@ check(cycleStatus.innocent.state == "loaded" and cycleData.items.FINE ~= nil,
"a mod beside the cycle loads normally") "a mod beside the cycle loads normally")
-- ------- inter-mod exports and find -- ------- inter-mod exports and find
_G.MOD_FIND_RESULTS = {} -- probes report through mod.exports: a mod's globals are its own
-- (src/mods/Sandbox.lua)
local exportLoader = Loader.new({ fs = memfs({ local exportLoader = Loader.new({ fs = memfs({
["mods/colorlib/manifest.json"] = manifestJson("colorlib"), ["mods/colorlib/manifest.json"] = manifestJson("colorlib"),
["mods/colorlib/main.lua"] = [[ ["mods/colorlib/main.lua"] = [[
@@ -356,7 +357,7 @@ end
}), }),
["mods/daynight/main.lua"] = [[ ["mods/daynight/main.lua"] = [[
return function(mod) return function(mod)
local results = _G.MOD_FIND_RESULTS local results = mod.exports
local color = mod.find("colorlib") local color = mod.find("colorlib")
results.depVersion = color.version results.depVersion = color.version
results.tint = color.exports.tint("dusk") results.tint = color.exports.tint("dusk")
@@ -371,7 +372,7 @@ end
["options.lua"] = "return { mods = { shelved = false } }", ["options.lua"] = "return { mods = { shelved = false } }",
}) }) }) })
check(exportLoader:load({}) == true, "the export fixture loads clean") check(exportLoader:load({}) == true, "the export fixture loads clean")
local found = _G.MOD_FIND_RESULTS local found = exportLoader.exports.daynight
check(found.tint == "tinted:dusk", "find returns the other mod's live export table") check(found.tint == "tinted:dusk", "find returns the other mod's live export table")
check(found.depVersion == "1.0.0", "the handle carries the other mod's version") check(found.depVersion == "1.0.0", "the handle carries the other mod's version")
check(found.optional == true, "an enabled optional dependency is findable") check(found.optional == true, "an enabled optional dependency is findable")
@@ -380,10 +381,8 @@ check(found.disabled == nil, "find returns nil for a disabled mod")
check(found.method == true, "mod:find is tolerated alongside mod.find") check(found.method == true, "mod:find is tolerated alongside mod.find")
check(exportLoader.order[1] == "colorlib", check(exportLoader.order[1] == "colorlib",
"a hard dependency executes before its dependent") "a hard dependency executes before its dependent")
_G.MOD_FIND_RESULTS = nil
-- ------- the rest of the v2 mod object -- ------- the rest of the v2 mod object
_G.MOD_OBJECT_PROBE = {}
local objectLoader = Loader.new({ fs = memfs({ local objectLoader = Loader.new({ fs = memfs({
["mods/probe/manifest.json"] = manifestJson("probe", { ["mods/probe/manifest.json"] = manifestJson("probe", {
api = "2", description = '"probing"', priority = "3", api = "2", description = '"probing"', priority = "3",
@@ -391,7 +390,7 @@ local objectLoader = Loader.new({ fs = memfs({
["mods/probe/data.txt"] = "hello from the mod dir", ["mods/probe/data.txt"] = "hello from the mod dir",
["mods/probe/main.lua"] = [[ ["mods/probe/main.lua"] = [[
return function(mod) return function(mod)
local probe = _G.MOD_OBJECT_PROBE local probe = mod.exports
probe.id, probe.version, probe.path = mod.id, mod.version, mod.path probe.id, probe.version, probe.path = mod.id, mod.version, mod.path
probe.manifestApi = mod.manifest.api probe.manifestApi = mod.manifest.api
mod.manifest.api = 99 mod.manifest.api = 99
@@ -423,7 +422,7 @@ end
["options.lua"] = "return { modOptions = { probe = { volume = 4 } } }", ["options.lua"] = "return { modOptions = { probe = { volume = 4 } } }",
}) }) }) })
check(objectLoader:load({ pokemon = {} }) == true, "the mod object fixture loads clean") check(objectLoader:load({ pokemon = {} }) == true, "the mod object fixture loads clean")
local probe = _G.MOD_OBJECT_PROBE local probe = objectLoader.exports.probe
check(probe.id == "probe" and probe.version == "1.0.0" and probe.path == "mods/probe", check(probe.id == "probe" and probe.version == "1.0.0" and probe.path == "mods/probe",
"identity fields are present") "identity fields are present")
check(probe.manifestApi == 2 and objectLoader.mods.probe.manifest.api == 2, check(probe.manifestApi == 2 and objectLoader.mods.probe.manifest.api == 2,
@@ -449,7 +448,6 @@ check(probe.onceCount == 1 and probe.stillHeard == true,
"events:once fires once and does not skip the listener behind it") "events:once fires once and does not skip the listener behind it")
check(tostring(probe.forgery):find("may only emit", 1, true) ~= nil, check(tostring(probe.forgery):find("may only emit", 1, true) ~= nil,
"a mod cannot emit outside its own event namespace") "a mod cannot emit outside its own event namespace")
_G.MOD_OBJECT_PROBE = nil
-- a failing entry chunk takes its exports, commands and migrations with it -- a failing entry chunk takes its exports, commands and migrations with it
local residueLoader = Loader.new({ fs = memfs({ local residueLoader = Loader.new({ fs = memfs({
+21 -23
View File
@@ -87,13 +87,19 @@ end
-- ------- a mod registers two pipelines and the engine dispatches them -- ------- a mod registers two pipelines and the engine dispatches them
local trace = {} -- The probe table is the mod's, published through mod.exports: a mod's
-- globals are its own now (src/mods/Sandbox.lua). The world/present folds
-- accept only a real Canvas, so the mod makes concrete ones to return and the
-- test pins identity through the dispatch.
local FILES = { local FILES = {
["mods/painter/manifest.json"] = manifest("painter", ',"priority":10'), ["mods/painter/manifest.json"] = manifest("painter", ',"priority":10'),
["mods/painter/main.lua"] = [[ ["mods/painter/main.lua"] = [[
local mod = ... local mod = ...
local T = _G.__RENDER_TEST local T = mod.exports
T.trace, T.available = {}, true
T.worldOut = love.graphics.newCanvas(2, 2)
T.blurOut = love.graphics.newCanvas(2, 2)
T.gradeOut = love.graphics.newCanvas(2, 2)
mod.content.render_pipelines:register("diorama", { mod.content.render_pipelines:register("diorama", {
label = "DIORAMA", label = "DIORAMA",
levels = { "OFF", "LOW", "HIGH" }, levels = { "OFF", "LOW", "HIGH" },
@@ -101,8 +107,6 @@ local FILES = {
priority = 20, priority = 20,
available = function() return T.available end, available = function() return T.available end,
update = function(dt, level) T.trace[#T.trace + 1] = "update:" .. level end, update = function(dt, level) T.trace[#T.trace + 1] = "update:" .. level end,
-- the folds composite only a real Canvas, so the mod hands back the
-- canvases the test pre-created (see T.worldOut / T.blurOut / T.gradeOut)
drawWorld = function(ctx) drawWorld = function(ctx)
T.trace[#T.trace + 1] = "world:" .. tostring(ctx.tag) T.trace[#T.trace + 1] = "world:" .. tostring(ctx.tag)
return T.worldOut return T.worldOut
@@ -123,17 +127,12 @@ local FILES = {
]], ]],
} }
_G.__RENDER_TEST = { trace = trace, available = true }
-- the world/present folds accept only a real Canvas, so give the mod concrete
-- ones to return and pin identity through the dispatch
_G.__RENDER_TEST.worldOut = love.graphics.newCanvas(2, 2)
_G.__RENDER_TEST.blurOut = love.graphics.newCanvas(2, 2)
_G.__RENDER_TEST.gradeOut = love.graphics.newCanvas(2, 2)
local data = {} local data = {}
local loader = Loader.new({ fs = memfs(FILES) }) local loader = Loader.new({ fs = memfs(FILES) })
local okLoad = loader:load(data) local okLoad = loader:load(data)
check(okLoad, "the pipeline mod loads clean: " .. table.concat(loader.errors, "; ")) check(okLoad, "the pipeline mod loads clean: " .. table.concat(loader.errors, "; "))
local RT = loader.exports.painter
local trace = RT.trace
Pipelines.install(data) Pipelines.install(data)
check(type(data.render_pipelines) == "table", check(type(data.render_pipelines) == "table",
@@ -169,24 +168,24 @@ Pipelines.setLevel("grade", 1)
eq(Pipelines.worldPipeline(), "diorama", eq(Pipelines.worldPipeline(), "diorama",
"the eligible world pipeline claims the world pass") "the eligible world pipeline claims the world pass")
eq(Pipelines.drawWorld("diorama", { tag = "ctx" }), _G.__RENDER_TEST.worldOut, eq(Pipelines.drawWorld("diorama", { tag = "ctx" }), RT.worldOut,
"drawWorld returns the mod's canvas") "drawWorld returns the mod's canvas")
eq(trace[#trace], "world:ctx", "drawWorld received the frame context") eq(trace[#trace], "world:ctx", "drawWorld received the frame context")
eq(Pipelines.worldPresent(_G.__RENDER_TEST.worldOut), _G.__RENDER_TEST.blurOut, eq(Pipelines.worldPresent(RT.worldOut), RT.blurOut,
"worldPresent folds its canvas over the world image") "worldPresent folds its canvas over the world image")
eq(Pipelines.wantsPresent(), true, "a live present pass asks for the canvas") eq(Pipelines.wantsPresent(), true, "a live present pass asks for the canvas")
eq(Pipelines.present(_G.__RENDER_TEST.gradeOut), _G.__RENDER_TEST.gradeOut, eq(Pipelines.present(RT.gradeOut), RT.gradeOut,
"present folds its canvas over the finished composite") "present folds its canvas over the finished composite")
-- ------- the hardware gate -- ------- the hardware gate
_G.__RENDER_TEST.available = false RT.available = false
eq(Pipelines.worldPipeline(), nil, eq(Pipelines.worldPipeline(), nil,
"an unavailable pipeline never takes the world pass") "an unavailable pipeline never takes the world pass")
eq(Pipelines.worldPresent("world-canvas"), "world-canvas", eq(Pipelines.worldPresent("world-canvas"), "world-canvas",
"an unavailable pipeline's worldPresent is skipped") "an unavailable pipeline's worldPresent is skipped")
_G.__RENDER_TEST.available = true RT.available = true
eq(Pipelines.worldPipeline(), "diorama", "availability is re-read each frame") eq(Pipelines.worldPipeline(), "diorama", "availability is re-read each frame")
-- ------- the gate governs input, never the draw -- ------- the gate governs input, never the draw
@@ -282,7 +281,8 @@ local SLOPPY = {
["mods/sloppy/manifest.json"] = manifest("sloppy"), ["mods/sloppy/manifest.json"] = manifest("sloppy"),
["mods/sloppy/main.lua"] = [[ ["mods/sloppy/main.lua"] = [[
local mod = ... local mod = ...
local T = _G.__SLOPPY local T = mod.exports
T.ran = 0
mod.content.render_pipelines:register("sloppy", { mod.content.render_pipelines:register("sloppy", {
label = "SLOPPY", label = "SLOPPY",
present = function(canvas) present = function(canvas)
@@ -302,20 +302,20 @@ local SLOPPY = {
}) })
]], ]],
} }
_G.__SLOPPY = { ran = 0 }
local sloppyData = {} local sloppyData = {}
local sloppyLoader = Loader.new({ fs = memfs(SLOPPY) }) local sloppyLoader = Loader.new({ fs = memfs(SLOPPY) })
sloppyLoader:load(sloppyData) sloppyLoader:load(sloppyData)
local SL = sloppyLoader.exports.sloppy
Pipelines.install(sloppyData) Pipelines.install(sloppyData)
local composite = love.graphics.newCanvas(4, 4) local composite = love.graphics.newCanvas(4, 4)
Pipelines.setLevel("sloppy", 1) Pipelines.setLevel("sloppy", 1)
for _, bad in ipairs({ "just-a-string", true, 42 }) do for _, bad in ipairs({ "just-a-string", true, 42 }) do
_G.__SLOPPY.ret = bad SL.ret = bad
eq(Pipelines.present(composite), composite, eq(Pipelines.present(composite), composite,
"a present returning a " .. type(bad) .. " leaves the composite untouched") "a present returning a " .. type(bad) .. " leaves the composite untouched")
end end
check(_G.__SLOPPY.ran == 3, "the present callback still ran each frame") check(SL.ran == 3, "the present callback still ran each frame")
check(Pipelines.eligible("sloppy") == true, check(Pipelines.eligible("sloppy") == true,
"a non-canvas return does not retire the pipeline as broken") "a non-canvas return does not retire the pipeline as broken")
Pipelines.setLevel("sloppy", 0) Pipelines.setLevel("sloppy", 0)
@@ -334,11 +334,9 @@ eq(love.graphics.getCanvas(), "engine-canvas",
eq(love.graphics.getBlendMode(), "alpha", eq(love.graphics.getBlendMode(), "alpha",
"a present that changed blend mode cannot leak it past the fold") "a present that changed blend mode cannot leak it past the fold")
Pipelines.setLevel("dirty", 0) Pipelines.setLevel("dirty", 0)
_G.__SLOPPY = nil
Pipelines.reset() Pipelines.reset()
Pipelines.install(nil) Pipelines.install(nil)
_G.__RENDER_TEST = nil
-- ------- and with no mods at all, the whole subsystem is inert -- ------- and with no mods at all, the whole subsystem is inert
+3 -4
View File
@@ -139,20 +139,20 @@ local files = {
["mods/probe/manifest.json"] = ["mods/probe/manifest.json"] =
'{"id":"probe","name":"probe","version":"1.0.0",' '{"id":"probe","name":"probe","version":"1.0.0",'
.. '"entry":"main.lua","api":2,"profile":"content"}', .. '"entry":"main.lua","api":2,"profile":"content"}',
-- mod.exports, not _G: a mod's globals are its own (src/mods/Sandbox.lua)
["mods/probe/main.lua"] = [[ ["mods/probe/main.lua"] = [[
return function(mod) _G.MOD_CHECKPOINTS = mod.checkpoints end return function(mod) mod.exports.checkpoints = mod.checkpoints end
]], ]],
} }
local game, ow = makeGame() local game, ow = makeGame()
local loader = Loader.new({ fs = memfs(files) }) local loader = Loader.new({ fs = memfs(files) })
loader.game = game loader.game = game
T.check(loader:load({}) == true, "checkpoint fixture mod loads") T.check(loader:load({}) == true, "checkpoint fixture mod loads")
local checkpoints = _G.MOD_CHECKPOINTS local checkpoints = (loader.exports.probe or {}).checkpoints
T.check(type(checkpoints) == "table", T.check(type(checkpoints) == "table",
"Loader exposes mod.checkpoints through the public mod object") "Loader exposes mod.checkpoints through the public mod object")
if type(checkpoints) ~= "table" then if type(checkpoints) ~= "table" then
Runtime.events, Runtime.hooks = savedEvents, savedHooks Runtime.events, Runtime.hooks = savedEvents, savedHooks
_G.MOD_CHECKPOINTS = nil
T.finish() T.finish()
end end
@@ -435,7 +435,6 @@ end
Runtime.events, Runtime.hooks = savedEvents, savedHooks Runtime.events, Runtime.hooks = savedEvents, savedHooks
Runtime.currentMod = nil Runtime.currentMod = nil
_G.MOD_CHECKPOINTS = nil
love.math.getRandomState = oldGetRandomState love.math.getRandomState = oldGetRandomState
love.math.setRandomState = oldSetRandomState love.math.setRandomState = oldSetRandomState
+15 -18
View File
@@ -23,16 +23,16 @@ local FIXTURE = {
extraPolls = extraPolls + 1 extraPolls = extraPolls + 1
if not paused then nextFn(game, dt) end if not paused then nextFn(game, dt) end
end) end)
local vetoQuit = false
mod.hooks:wrap("core.quit_to_launcher", function(nextFn) mod.hooks:wrap("core.quit_to_launcher", function(nextFn)
if os.getenv("FIXTURE_VETO_QUIT") == "1" then return false end if vetoQuit then return false end
return nextFn() return nextFn()
end) end)
-- test-only knobs, read back through mod.storage-free globals since -- test-only knobs on mod.exports: a sandboxed mod has no shared _G to
-- this fixture never leaves the process -- smuggle them through (src/mods/Sandbox.lua)
_G.__fixturePlatformBridge = { mod.exports.setPaused = function(v) paused = v end
setPaused = function(v) paused = v end, mod.exports.extraPolls = function() return extraPolls end
extraPolls = function() return extraPolls end, mod.exports.setVetoQuit = function(v) vetoQuit = v end
}
]], ]],
} }
@@ -43,23 +43,23 @@ do
T.eq(#run.errors, 0, T.eq(#run.errors, 0,
"the fixture mod loads clean (" .. tostring(run.errors[1]) .. ")") "the fixture mod loads clean (" .. tostring(run.errors[1]) .. ")")
local bridge = run.loader.exports.fix_platform_bridge
local calls = 0 local calls = 0
local fakeGame = { update = function(self, dt) calls = calls + 1 end } local fakeGame = { update = function(self, dt) calls = calls + 1 end }
_G.__fixturePlatformBridge.setPaused(false) bridge.setPaused(false)
PlatformHooks.update(fakeGame, 1 / 60) PlatformHooks.update(fakeGame, 1 / 60)
T.eq(calls, 1, "unpaused: vanilla Game:update runs") T.eq(calls, 1, "unpaused: vanilla Game:update runs")
T.eq(_G.__fixturePlatformBridge.extraPolls(), 1, T.eq(bridge.extraPolls(), 1,
"the subscriber's wrapper runs every frame") "the subscriber's wrapper runs every frame")
_G.__fixturePlatformBridge.setPaused(true) bridge.setPaused(true)
PlatformHooks.update(fakeGame, 1 / 60) PlatformHooks.update(fakeGame, 1 / 60)
T.eq(calls, 1, "paused: vanilla Game:update is skipped") T.eq(calls, 1, "paused: vanilla Game:update is skipped")
T.eq(_G.__fixturePlatformBridge.extraPolls(), 2, T.eq(bridge.extraPolls(), 2,
"the subscriber keeps polling every frame while paused") "the subscriber keeps polling every frame while paused")
run.release() run.release()
_G.__fixturePlatformBridge = nil
end end
-- core.quit_to_launcher: a subscriber can veto without the vanilla -- core.quit_to_launcher: a subscriber can veto without the vanilla
@@ -70,11 +70,8 @@ do
T.eq(#run.errors, 0, T.eq(#run.errors, 0,
"the fixture mod loads clean (" .. tostring(run.errors[1]) .. ")") "the fixture mod loads clean (" .. tostring(run.errors[1]) .. ")")
local realGetenv = os.getenv local bridge = run.loader.exports.fix_platform_bridge
os.getenv = function(name) bridge.setVetoQuit(true)
if name == "FIXTURE_VETO_QUIT" then return "1" end
return realGetenv(name)
end
local vanillaCalls = 0 local vanillaCalls = 0
local vetoed = PlatformHooks.quitToLauncher(function() local vetoed = PlatformHooks.quitToLauncher(function()
vanillaCalls = vanillaCalls + 1 vanillaCalls = vanillaCalls + 1
@@ -82,7 +79,7 @@ do
end) end)
T.eq(vetoed, false, "a subscriber can veto the return-to-launcher decision") T.eq(vetoed, false, "a subscriber can veto the return-to-launcher decision")
T.eq(vanillaCalls, 0, "a veto never evaluates the vanilla condition") T.eq(vanillaCalls, 0, "a veto never evaluates the vanilla condition")
os.getenv = realGetenv bridge.setVetoQuit(false)
local passed = PlatformHooks.quitToLauncher(function() return true end) local passed = PlatformHooks.quitToLauncher(function() return true end)
T.eq(passed, true, "with no veto, the vanilla decision passes through unchanged") T.eq(passed, true, "with no veto, the vanilla decision passes through unchanged")
+207
View File
@@ -0,0 +1,207 @@
-- 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.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Manifest = require("src.mods.Manifest")
local Sandbox = require("src.mods.Sandbox")
local SafePath = require("src.mods.SafePath")
local function manifest(id, extra)
return ('{"id":"%s","name":"%s","version":"1.0.0","entry":"main.lua",'
.. '"api":2%s}'):format(id, id, extra or "")
end
-- what a probe reports back; pcall'd so one broken assumption does not take
-- the whole entry chunk down and hide the rest
local PROBE = [[
local mod = ...
local out = mod.exports
out.io = io
out.package = package
out.dofile = dofile
out.loadfile = loadfile
out.setfenv = setfenv
out.getfenv = getfenv
out.debug = debug
out.osGetenv = os.getenv
out.osExecute = os.execute
out.osRemove = os.remove
out.osTime = type(os.time)
out.stringOk = ("a"):rep(3)
local function attempt(fn, ...)
local ok, err = pcall(fn, ...)
if ok then return false end
return tostring(err)
end
out.requireIo = attempt(require, "io")
out.requireOs = attempt(require, "os")
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 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.childSharesEnv = childG == _G
out.readEscape = attempt(function() return mod:read("../../secret.txt") end)
out.readAbsolute = attempt(function() return mod:read("/etc/hosts") end)
out.readBackslash = attempt(function() return mod:read("..\\secret.txt") end)
out.assetsEscape = attempt(function() return mod.assets:path("../../x.png") end)
out.readOwn = mod:read("data/note.txt")
_G.SANDBOX_LEAK = "escaped"
out.globalsAreOwn = _G ~= nil and _G.SANDBOX_LEAK == "escaped"
-- a mod stomping the standard library must not reach the engine
table.insert = function() error("stomped") end
string.format = function() error("stomped") end
]]
local FILES = {
["mods/fix_sandbox/manifest.json"] = manifest("fix_sandbox"),
["mods/fix_sandbox/main.lua"] = PROBE,
["mods/fix_sandbox/data/note.txt"] = "own file",
}
local run = T.sdk.loadMods({ "mods/fix_sandbox" }, { fs = T.sdk.memfs(FILES) })
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
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.stringOk, "aaa", "the safe standard library is intact")
-- ------- require, the one call that would undo all of the above
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")
T.check(out.requireSocket and out.requireSocket:find("network", 1, true),
"a network module 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 ~= false, "love.system is refused: openURL launches anything")
T.eq(out.loveGraphics, "table", "the rest of love passes through")
T.check(out.loveAssign ~= false, "a mod cannot assign into the love facade")
-- ------- env propagation and isolation
T.eq(out.childIo, nil,
"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.childSharesEnv, "the child chunk shares the mod's own globals table")
T.check(out.globalsAreOwn, "a mod's globals write to its own table")
T.eq(_G.SANDBOX_LEAK, nil, "and never reach the engine's _G")
T.eq(("%d"):format(1), "1",
"a mod stomping string.format cannot reach the engine's copy")
do
local probe = {}
table.insert(probe, "still works")
T.eq(probe[1], "still works",
"nor table.insert -- each mod gets its own standard-library namespace")
end
-- ------- paths
T.check(out.readEscape and out.readEscape:find("must stay inside", 1, true),
"mod:read cannot climb out of the mod directory: " .. tostring(out.readEscape))
T.check(out.readAbsolute ~= false, "mod:read refuses an absolute path")
T.check(out.readBackslash ~= false, "mod:read refuses a backslash climb")
T.check(out.assetsEscape ~= false, "mod.assets:path refuses a climb")
T.eq(out.readOwn, "own file", "and the mod's own files still read")
run.release()
-- ------- the grammar itself
for _, bad in ipairs({ "../x", "a/../../x", "/etc/hosts", "C:/Windows/x",
"..\\x", "a\\b", "..", ".", "" }) do
T.eq(SafePath.safe(bad), nil, ("SafePath rejects %q"):format(bad))
end
T.eq(SafePath.safe("maps/NEW_BARK_TOWN.lua"), "maps/NEW_BARK_TOWN.lua",
"an ordinary relative path passes")
T.eq(SafePath.safe("./main.lua"), "main.lua",
"a leading ./ is normalized rather than rejected, so older manifests load")
-- ------- manifest paths are untrusted input too
T.check(not pcall(Manifest.validate,
{ id = "evil", name = "evil", version = "1.0.0", entry = "../../../evil.lua" }),
"a manifest cannot point entry outside the mod directory")
T.check(not pcall(Manifest.validate,
{ id = "evil", name = "evil", version = "1.0.0", entry = "main.lua",
options_schema = "../../options.lua" }),
"nor options_schema")
T.check(pcall(Manifest.validate,
{ id = "fine", name = "fine", version = "1.0.0", entry = "main.lua" }),
"an ordinary manifest still validates")
-- ------- bytecode
do
local bad = {
["mods/fix_bytecode/manifest.json"] = manifest("fix_bytecode"),
["mods/fix_bytecode/main.lua"] = string.dump(function() end),
}
local bytecodeRun = T.sdk.loadMods({ "mods/fix_bytecode" },
{ fs = T.sdk.memfs(bad) })
T.eq(#bytecodeRun.errors, 1, "a mod that ships bytecode fails to load")
T.check(tostring(bytecodeRun.errors[1]):find("bytecode", 1, true),
"and says why: " .. tostring(bytecodeRun.errors[1]))
bytecodeRun.release()
end
-- ------- the sandbox is not opt-in
do
local env = Sandbox.envFor({ modId = "probe" })
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.check(not pcall(env.require, "io"), "and its require refuses io")
end
T.finish("sandbox")
+5 -5
View File
@@ -66,12 +66,13 @@ end
local files = { local files = {
["mods/alpha/manifest.json"] = manifest("alpha"), ["mods/alpha/manifest.json"] = manifest("alpha"),
-- mod.exports, not _G: a mod's globals are its own (src/mods/Sandbox.lua)
["mods/alpha/main.lua"] = [[ ["mods/alpha/main.lua"] = [[
return function(mod) _G.MOD_STORAGE_ALPHA = mod.storage end return function(mod) mod.exports.storage = mod.storage end
]], ]],
["mods/beta/manifest.json"] = manifest("beta"), ["mods/beta/manifest.json"] = manifest("beta"),
["mods/beta/main.lua"] = [[ ["mods/beta/main.lua"] = [[
return function(mod) _G.MOD_STORAGE_BETA = mod.storage end return function(mod) mod.exports.storage = mod.storage end
]], ]],
} }
local fs = memfs(files) local fs = memfs(files)
@@ -80,12 +81,12 @@ local current = game("red", "play-a")
loader.game = current loader.game = current
T.check(loader:load({}) == true, "storage fixture mods load") T.check(loader:load({}) == true, "storage fixture mods load")
local alpha, beta = _G.MOD_STORAGE_ALPHA, _G.MOD_STORAGE_BETA local alpha = (loader.exports.alpha or {}).storage
local beta = (loader.exports.beta or {}).storage
T.check(type(alpha) == "table" and type(beta) == "table", T.check(type(alpha) == "table" and type(beta) == "table",
"Loader exposes mod.storage through the public mod object") "Loader exposes mod.storage through the public mod object")
if type(alpha) ~= "table" or type(beta) ~= "table" then if type(alpha) ~= "table" or type(beta) ~= "table" then
Runtime.events, Runtime.hooks = savedEvents, savedHooks Runtime.events, Runtime.hooks = savedEvents, savedHooks
_G.MOD_STORAGE_ALPHA, _G.MOD_STORAGE_BETA = nil, nil
T.finish() T.finish()
end end
@@ -176,6 +177,5 @@ T.eq(next(emptyFiles), nil, "no-mod boot creates no storage paths or files")
Runtime.events, Runtime.hooks = savedEvents, savedHooks Runtime.events, Runtime.hooks = savedEvents, savedHooks
Runtime.currentMod = nil Runtime.currentMod = nil
_G.MOD_STORAGE_ALPHA, _G.MOD_STORAGE_BETA = nil, nil
T.finish() T.finish()
@@ -57,13 +57,16 @@ local files = {
["mods/probe/manifest.json"] = ["mods/probe/manifest.json"] =
'{"id":"probe","name":"probe","version":"1.0.0",' '{"id":"probe","name":"probe","version":"1.0.0",'
.. '"entry":"main.lua","api":2,"profile":"content"}', .. '"entry":"main.lua","api":2,"profile":"content"}',
-- mod.exports, not _G: a mod's globals are its own (src/mods/Sandbox.lua)
["mods/probe/main.lua"] = [[ ["mods/probe/main.lua"] = [[
return function(mod) return function(mod)
_G.MOD_TITLE_STORAGE = mod.storage local out = mod.exports
_G.MOD_TITLE_CHECKPOINTS = mod.checkpoints out.storage = mod.storage
out.checkpoints = mod.checkpoints
out.restoreCount = 0
mod.events:on("checkpoint.restored", function(ev) mod.events:on("checkpoint.restored", function(ev)
_G.MOD_TITLE_RESTORE_COUNT = (_G.MOD_TITLE_RESTORE_COUNT or 0) + 1 out.restoreCount = out.restoreCount + 1
_G.MOD_TITLE_RESTORE_KIND = ev.kind out.restoreKind = ev.kind
end) end)
end end
]], ]],
@@ -82,7 +85,8 @@ local loader = Loader.new({ fs = fs })
loader.game = active loader.game = active
T.check(loader:load({}) == true, "title-context fixture mod loads") T.check(loader:load({}) == true, "title-context fixture mod loads")
local storage = _G.MOD_TITLE_STORAGE local probe = loader.exports.probe or {}
local storage = probe.storage
T.check(type(storage) == "table", "loader exposes the public storage facade") T.check(type(storage) == "table", "loader exposes the public storage facade")
if type(storage) == "table" then if type(storage) == "table" then
local written, writeCode, writeMessage = storage:write(active, "history/index", { local written, writeCode, writeMessage = storage:write(active, "history/index", {
@@ -178,7 +182,7 @@ if type(storage) == "table" then
end end
local runtime = makeRuntime(active.save, false) local runtime = makeRuntime(active.save, false)
local checkpoints = _G.MOD_TITLE_CHECKPOINTS local checkpoints = probe.checkpoints
T.check(type(checkpoints) == "table", "loader exposes the public checkpoint facade") T.check(type(checkpoints) == "table", "loader exposes the public checkpoint facade")
local checkpoint = checkpoints and checkpoints:capture(runtime) local checkpoint = checkpoints and checkpoints:capture(runtime)
T.check(type(checkpoint) == "table", T.check(type(checkpoint) == "table",
@@ -226,9 +230,9 @@ if type(storage) == "table" then
"title bootstrap never rewrites the first normal save") "title bootstrap never rewrites the first normal save")
T.same(checkpoints:capture(titleRuntime), checkpoint, T.same(checkpoints:capture(titleRuntime), checkpoint,
"bootstrapped overworld differentially recaptures the selected checkpoint") "bootstrapped overworld differentially recaptures the selected checkpoint")
T.eq(_G.MOD_TITLE_RESTORE_COUNT, 1, T.eq(probe.restoreCount, 1,
"a successfully verified title resume emits checkpoint.restored exactly once") "a successfully verified title resume emits checkpoint.restored exactly once")
T.eq(_G.MOD_TITLE_RESTORE_KIND, "overworld", T.eq(probe.restoreKind, "overworld",
"title resume lifecycle reports the reconstructed checkpoint kind") "title resume lifecycle reports the reconstructed checkpoint kind")
-- Force a failure after restoreCheckpointSave has already installed the -- Force a failure after restoreCheckpointSave has already installed the
@@ -255,7 +259,7 @@ if type(storage) == "table" then
version = "red", meta = { playthroughId = originalId }, version = "red", meta = { playthroughId = originalId },
}, fs).savedAt, anchoredAt, }, fs).savedAt, anchoredAt,
"failed title reconstruction never rewrites the normal Pokémon save") "failed title reconstruction never rewrites the normal Pokémon save")
T.eq(_G.MOD_TITLE_RESTORE_COUNT, 1, T.eq(probe.restoreCount, 1,
"failed title reconstruction emits no additional restored lifecycle event") "failed title reconstruction emits no additional restored lifecycle event")
end end
@@ -290,10 +294,6 @@ end
Runtime.events, Runtime.hooks = savedEvents, savedHooks Runtime.events, Runtime.hooks = savedEvents, savedHooks
Runtime.currentMod = nil Runtime.currentMod = nil
_G.MOD_TITLE_STORAGE = nil
_G.MOD_TITLE_CHECKPOINTS = nil
_G.MOD_TITLE_RESTORE_COUNT = nil
_G.MOD_TITLE_RESTORE_KIND = nil
SaveData.resetSlotState() SaveData.resetSlotState()
SaveData.loadOptions = originalLoadOptions SaveData.loadOptions = originalLoadOptions
love.filesystem = realFs love.filesystem = realFs
+6 -4
View File
@@ -125,8 +125,11 @@ local hotFiles = {
["mods/hot_mod/main.lua"] = [[ ["mods/hot_mod/main.lua"] = [[
return function(mod) return function(mod)
mod.content.pokemon:patch("FIXMON_A", { baseStats = { speed = 99 } }) mod.content.pokemon:patch("FIXMON_A", { baseStats = { speed = 99 } })
-- counted on mod.exports, not _G: a mod's globals are its own
-- (src/mods/Sandbox.lua), and a reload gives it a fresh table
local out = mod.exports
mod.events:on("game.ready", function() mod.events:on("game.ready", function()
_G.MODKIT_TEST_READY = (_G.MODKIT_TEST_READY or 0) + 1 out.ready = (out.ready or 0) + 1
end) end)
end end
]], ]],
@@ -148,7 +151,6 @@ local function freshHotData()
return d return d
end end
_G.MODKIT_TEST_READY = 0
local hotData = freshHotData() local hotData = freshHotData()
local game = { data = hotData, save = { modData = {} } } local game = { data = hotData, save = { modData = {} } }
local bootLoader = Loader.new({ fs = hotFs }) local bootLoader = Loader.new({ fs = hotFs })
@@ -209,7 +211,8 @@ check(deepEqual(hotData.pokemon.FIXMON_B, fixture.load().pokemon.FIXMON_B),
"untouched base record is byte-identical after reload") "untouched base record is byte-identical after reload")
check(flushed >= 1, "reload flushed the registered caches") check(flushed >= 1, "reload flushed the registered caches")
check(summary:find("reloaded 1 mods", 1, true) ~= nil, "reload summary counts") check(summary:find("reloaded 1 mods", 1, true) ~= nil, "reload summary counts")
check(_G.MODKIT_TEST_READY >= 1, "game.ready re-reaches re-subscribed mods") check((game.mods.exports.hot_mod or {}).ready >= 1,
"game.ready re-reaches re-subscribed mods")
ChipAudio.stopMusic = savedStopMusic ChipAudio.stopMusic = savedStopMusic
check(musicStops >= 1, "reload stops chip music through the cache bus") check(musicStops >= 1, "reload stops chip music through the cache bus")
Sound.play(beepData, "Fix_Beep") Sound.play(beepData, "Fix_Beep")
@@ -228,7 +231,6 @@ local broken = HotReload.run(game, { fs = hotFs })
check(#broken.errors > 0, "broken edit lands in the error feed") check(#broken.errors > 0, "broken edit lands in the error feed")
check(hotData.pokemon.FIXMON_A.baseStats.speed == 45, check(hotData.pokemon.FIXMON_A.baseStats.speed == 45,
"broken mod rolls back to pristine base") "broken mod rolls back to pristine base")
_G.MODKIT_TEST_READY = nil
-- ------- dev console: repl, verbs, tracer, input isolation -- ------- dev console: repl, verbs, tracer, input isolation