Merge branch 'grandmas-kitchen' into dev

This commit is contained in:
bryanthaboi
2026-08-14 07:13:00 -04:00
26 changed files with 1170 additions and 148 deletions
+43 -2
View File
@@ -242,7 +242,48 @@ 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.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
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 (see [Manifest specification](docs/modding.md#manifest-specification-manifestjson)). permissions, profile (see [Manifest specification](docs/modding.md#manifest-specification-manifestjson)).
@@ -264,7 +305,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`,
+38
View File
@@ -630,3 +630,41 @@ local both = mod.datetime:dateTime(game, createdAt)
The live `game` supplies only the current option context. Formatting never The live `game` supplies only the current option context. Formatting never
mutates the save, options, or timestamp, and invalid timestamps return mutates the save, options, or timestamp, and invalid timestamps return
`"----"`. `"----"`.
## Device power information
Sandboxed mods can read the host's battery state without receiving the rest
of `love.system`:
```lua
local state, percent = mod.device:powerInfo()
```
`state` follows LÖVE's values: `"unknown"`, `"battery"`, `"nobattery"`,
`"charging"`, or `"charged"`. `percent` is `0` through `100`, or `nil` when
the platform cannot report it. The facade is read-only and does not expose
URL launching, clipboard access, or other system operations.
## Real-world steps
On iOS and Android the game counts the player's real-world steps natively
(HealthKit / the hardware step counter). A mod reaches that bridge through
the `steps` permission in `manifest.json`, which the player sees in the
mod manager like every other permission:
```lua
if mod.steps:available() then
mod.steps:sync() -- async; OS consent sheet on first use
end
-- later, at a quiet moment:
local walk = mod.steps:poll() -- { steps = n, from = ?, to = ? } or nil
```
`available()` is `false` on builds without the bridge (desktop) and for
mods without the permission, so a probe is always safe. `sync()` asks the
platform to refresh its count and returns whether there was a bridge to
ask. `poll()` returns the next delivery for this mod — the engine consumes
the native side's pending file itself, each permissioned mod receives its
own copy of a delivery, and steps are anchored natively so the same walk
is never delivered twice. Without the permission, `sync` and `poll` raise
an error naming it.
+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**
+49
View File
@@ -0,0 +1,49 @@
# RFC 0008 — Read-only device power information for sandboxed mods
## Status
Proposed. Engine: `Loader.lua`, `Sandbox.lua`. Test:
`tests/modkit/cases/device_power_info.lua`.
## Motivation
A handheld UI mod can show the player's battery state and warn before power
loss. The sandbox correctly removes `love.system` because that module also
launches URLs and exposes other host operations, but it leaves no scoped way
to read the harmless power values that LÖVE already provides.
## The decision it extends
Extends the mod sandbox in `src/mods/Sandbox.lua`: blocked host modules stay
blocked while legitimate operations receive narrow engine-owned facades.
## The exact API delta
Add `mod.device:powerInfo() -> state, percent`.
The engine calls `love.system.getPowerInfo()` outside the mod sandbox and
returns only its first two values. `state` is one of LÖVE's standard power
states. `percent` is `0` through `100` or `nil`. When the platform has no
power-information backend, the result is `"unknown", nil`.
No permission grants access to `love.system`; URL launching, clipboard access,
OS identification, and the module table itself remain unavailable.
## Migration note for existing mods
Mods that used `love.system.getPowerInfo()` replace that call with
`mod.device:powerInfo()`. No other mod changes.
## Parity tests
- **No mod:** loading no mods does not call the platform power backend.
- **Mod API:** a fixture mod loaded through the public loader receives state
and percentage through `mod.device`, while the existing sandbox suite keeps
proving that direct `love.system` access is refused.
- **Unavailable backend:** the public facade returns `"unknown", nil` rather
than inventing battery data or failing mod load.
## Deprecation etiquette
Nothing deprecated. The facade is additive; the sandbox's `love.system` block
remains in force.
+68
View File
@@ -0,0 +1,68 @@
# RFC 0009 — Permission-gated step bridge for sandboxed mods
## Status
Proposed. Engine: `Steps.lua` (new), `Loader.lua`, `Manifest.lua`,
`Sandbox.lua`. Test: `tests/modkit/cases/steps_bridge.lua`. Issue: #1186.
## Motivation
The iOS and Android builds count the player's real-world steps natively
(#452, #489), exposed to Lua as `love.system.syncHealthSteps()` and
delivered as `steps_pending.json` in the save-directory root. The sandbox
correctly blocks both — `love.system` also launches URLs, and the file API
names paths — but that leaves the bridge with no consumer: the mod that
step counting was built for (Pokéwalker, steps→EXP) can no longer be
written.
## The decision it extends
Extends the mod sandbox in `src/mods/Sandbox.lua` (blocked host modules
stay blocked; legitimate operations receive narrow engine-owned facades)
and the permission model `network` established: a `manifest.json`
permission the player sees in the mod manager that genuinely gates a
capability.
## The exact API delta
A new manifest permission token, `steps`, and a `mod.steps` facade:
- `mod.steps:available() -> boolean` — whether this build carries the
native bridge. Answers `false` without the permission, so a probe stays
quiet.
- `mod.steps:sync() -> boolean` — asks the platform to refresh its count
(async; the OS consent sheet still appears on first use, exactly as
before the sandbox). `false` when there is no bridge.
- `mod.steps:poll() -> { steps = n, from = iso?, to = iso? } | nil` — the
next delivery for this mod, engine-consumed from the pending file. Each
permissioned mod receives its own copy of a delivery.
Without the permission, `sync` and `poll` raise an error naming the
missing permission, the way the network gate does. The engine owns the
pending file: mods never learn its name or location, and only the three
contract fields travel. No new event, hook, or registry names.
## Migration note for existing mods
Mods that called `love.system.syncHealthSteps()` and read
`steps_pending.json` themselves add `"steps"` to `permissions` and switch
to `mod.steps:sync()` / `mod.steps:poll()`. No other mod changes.
## Parity tests
- **No mod:** with nothing installed the bridge is never called and a
pending file on disk is left untouched.
- **Mod API:** a fixture mod with the permission syncs and receives a
delivery through the public loader; two permissioned mods both receive
the same walk; a second poll returns nil.
- **No permission:** `available()` is false and the acting calls name the
missing permission; the sandbox suite keeps proving direct
`love.system` access is refused.
- **Malformed delivery:** a bad or empty pending file is dropped whole
rather than crashing a poll (the native anchor only advances on a
successful sync, so nothing is lost).
## Deprecation etiquette
Nothing deprecated. The facade is additive; the sandbox's `love.system`
and `love.filesystem` blocks remain in force.
+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
+94 -23
View File
@@ -12,12 +12,15 @@ 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")
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 Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local Steps = require("src.mods.Steps")
local Loader = {} local Loader = {}
Loader.__index = Loader Loader.__index = Loader
@@ -72,10 +75,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 +140,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 +194,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 +202,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 +252,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 = {}, stepsQueues = {},
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
@@ -357,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
@@ -998,6 +1017,40 @@ function Loader:_api(mod)
return DateTime.dateTime(game, timestamp) return DateTime.dateTime(game, timestamp)
end, end,
}, },
-- The read-only part of love.system that device UIs legitimately need.
-- Do not expose the module: openURL and clipboard access stay sandboxed.
device = {
powerInfo = function()
local getPowerInfo = love and love.system and love.system.getPowerInfo
if not getPowerInfo then return "unknown", nil end
local state, percent = getPowerInfo()
return state, percent
end,
},
-- The native step bridge (#1186), behind the "steps" permission the
-- player sees in the mod manager: sync asks the platform to refresh
-- its count, poll hands this mod its copy of what the bridge
-- delivered. The engine owns the pending file -- a mod never names a
-- path, it only receives { steps, from, to }. available() answers
-- false without the permission (a probe stays quiet); the calls that
-- would do something name the missing permission instead, the way the
-- network gate does.
steps = (function()
if mod.manifest.permissionSet.steps then
loader.stepsQueues[modId] = loader.stepsQueues[modId] or {}
return {
available = function() return Steps.available() end,
sync = function() return Steps.sync() end,
poll = function() return Steps.poll(loader, modId) end,
}
end
local function refuse()
error(('[%s] mod.steps needs the "steps" permission in '
.. "manifest.json"):format(modId), 2)
end
return { available = function() return false end,
sync = refuse, poll = refuse }
end)(),
-- namespaced per mod; M11 backs these with save.modData / -- namespaced per mod; M11 backs these with save.modData /
-- options.modOptions, the shape mods compile against is already final -- options.modOptions, the shape mods compile against is already final
save = { save = {
@@ -1108,9 +1161,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,
@@ -1120,9 +1175,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
@@ -1166,9 +1222,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)
@@ -1200,6 +1268,7 @@ function Loader:_rollback(modId)
self.optionSchemas[modId] = nil self.optionSchemas[modId] = nil
self.migrations[modId] = nil self.migrations[modId] = nil
self.modSave[modId] = nil self.modSave[modId] = nil
self.stepsQueues[modId] = nil
end end
-- a mod that explicitly swears it stays link-compatible while writing into a -- a mod that explicitly swears it stays link-compatible while writing into a
@@ -1383,10 +1452,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")
@@ -935,13 +937,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
+15 -4
View File
@@ -3,13 +3,15 @@
-- 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")
local Manifest = {} local Manifest = {}
Manifest.PROFILES = { content = true, overhaul = true, total_conversion = true } Manifest.PROFILES = { content = true, overhaul = true, total_conversion = true }
Manifest.PERMISSIONS = { network = true, filesystem = true, engine_internals = true } Manifest.PERMISSIONS = { network = true, filesystem = true,
engine_internals = true, steps = true }
-- link-relevant registries; a mod that writes into one of these while -- link-relevant registries; a mod that writes into one of these while
-- declaring affects_link = false gets an attributed warning from the loader -- declaring affects_link = false gets an attributed warning from the loader
@@ -187,6 +189,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")
@@ -272,19 +277,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),
@@ -308,7 +318,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
+228
View File
@@ -0,0 +1,228 @@
-- 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 = "mod.device:powerInfo() for battery information, mod.steps for "
.. "the step bridge", 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
+84
View File
@@ -0,0 +1,84 @@
-- The scoped seam for the native step bridge (#1186).
--
-- The iOS/Android builds count the player's real-world steps natively
-- (#452, #489) and deliver them by writing steps_pending.json into the
-- save-directory root. Before the sandbox, the Pokéwalker mod called
-- love.system.syncHealthSteps() and consumed that file itself; the sandbox
-- blocks both, which is correct -- love.system launches URLs and the file
-- API names paths -- but it left the bridge with no consumer at all.
--
-- This module is the narrow replacement, gated by the "steps" permission
-- in manifest.json (the network model: a permission the player sees that
-- genuinely gates a capability). The engine owns the file: mods never
-- learn its name or location, they receive only the three contract fields
-- ({ steps, from, to }), each permissioned mod gets its own copy, and the
-- merge-don't-overwrite anchor semantics stay on the native side where
-- they always lived.
--
-- No frame pump: the file is looked for lazily when a mod polls, so a
-- build with no permissioned mod installed never touches the bridge or
-- the disk.
local Json = require("src.link.Json")
local Steps = {}
-- The native contract's drop point, in the save-directory root (see
-- mobile/ios and mobile/android step bridges).
Steps.PENDING = "steps_pending.json"
local function bridge()
return _G.love and _G.love.system and _G.love.system.syncHealthSteps
end
-- Whether this build carries the native bridge. Desktop builds do not;
-- a mod uses this to stay dormant without probing love.system.
function Steps.available()
return bridge() ~= nil
end
-- Ask the native side to refresh its count. Async: the result lands in
-- the pending file and comes back through a later poll. The platform's
-- own consent sheet (HealthKit / ACTIVITY_RECOGNITION) still appears on
-- first use, exactly as it did pre-sandbox. false when there is no
-- bridge to ask.
function Steps.sync()
local fn = bridge()
if not fn then return false end
fn()
return true
end
-- Consume the pending file, if one has appeared, and fan its payload out
-- to every permissioned mod's queue. Only the contract fields travel;
-- anything else in the file stays in the file's grave. A malformed or
-- empty delivery is dropped whole -- the native anchor only advances on a
-- successful sync, so nothing is lost to a bad write.
function Steps.pump(loader)
local fs = _G.love and _G.love.filesystem
if not (fs and fs.getInfo(Steps.PENDING, "file")) then return end
local raw = fs.read(Steps.PENDING)
fs.remove(Steps.PENDING)
if not raw then return end
local ok, decoded = pcall(Json.decode, raw)
if not ok or type(decoded) ~= "table" then return end
local steps = tonumber(decoded.steps)
if not steps or steps <= 0 then return end
local payload = { steps = steps, from = decoded.from, to = decoded.to }
for _, queue in pairs(loader.stepsQueues) do
queue[#queue + 1] = { steps = payload.steps, from = payload.from,
to = payload.to }
end
end
-- The next delivery for this mod, or nil. Each permissioned mod consumes
-- its own queue, so two mods both see the same walk (pre-sandbox, whoever
-- read the file first won).
function Steps.poll(loader, modId)
Steps.pump(loader)
local queue = loader.stepsQueues[modId]
if not queue then return nil end
return table.remove(queue, 1)
end
return Steps
@@ -82,10 +82,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
@@ -135,11 +136,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 }))
@@ -149,15 +151,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
@@ -359,7 +359,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"] = [[
@@ -374,7 +375,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")
@@ -389,7 +390,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")
@@ -398,10 +399,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",
@@ -409,7 +408,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
@@ -441,7 +440,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,
@@ -467,7 +466,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
+6 -7
View File
@@ -139,10 +139,11 @@ 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_CHECKPOINTS = mod.checkpoints mod.exports.checkpoints = mod.checkpoints
_G.MOD_HOOKS = mod.hooks mod.exports.hooks = mod.hooks
end end
]], ]],
} }
@@ -150,12 +151,12 @@ 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
local modHooks = (loader.exports.probe or {}).hooks
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
@@ -440,7 +441,7 @@ end
-- at the restored safe decision reaches its semantic auxiliary action without -- at the restored safe decision reaches its semantic auxiliary action without
-- selecting a native command. -- selecting a native command.
local auxiliaryCalls = 0 local auxiliaryCalls = 0
_G.MOD_HOOKS:wrap("battle.menu_auxiliary", function(nextFn, liveGame, context) modHooks:wrap("battle.menu_auxiliary", function(nextFn, liveGame, context)
auxiliaryCalls = auxiliaryCalls + 1 auxiliaryCalls = auxiliaryCalls + 1
T.check(liveGame == battleGame, "public battle auxiliary action receives the game") T.check(liveGame == battleGame, "public battle auxiliary action receives the game")
T.same(context, { kind = "wild" }, "public auxiliary context is data-only") T.same(context, { kind = "wild" }, "public auxiliary context is data-only")
@@ -456,8 +457,6 @@ T.eq(boundary.menuIndex, originalMenuIndex, "public auxiliary hook preserves cur
Runtime.events, Runtime.hooks = savedEvents, savedHooks Runtime.events, Runtime.hooks = savedEvents, savedHooks
Runtime.currentMod = nil Runtime.currentMod = nil
_G.MOD_CHECKPOINTS = nil
_G.MOD_HOOKS = nil
love.math.getRandomState = oldGetRandomState love.math.getRandomState = oldGetRandomState
love.math.setRandomState = oldSetRandomState love.math.setRandomState = oldSetRandomState
+52
View File
@@ -0,0 +1,52 @@
-- A sandboxed mod may read battery state without receiving love.system and
-- its process-launching surface.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local FIXTURE = {
["mods/power_probe/manifest.json"] = [[{
"id": "power_probe",
"name": "Power Probe",
"version": "1.0.0",
"entry": "main.lua",
"api": 2
}]],
["mods/power_probe/main.lua"] = [[
local mod = ...
mod.exports.state, mod.exports.percent = mod.device:powerInfo()
]],
}
local saved = T.love.system.getPowerInfo
local calls = 0
T.love.system.getPowerInfo = function()
calls = calls + 1
return "charging", 42, 900
end
local vanilla = T.sdk.loadNone({})
T.eq(calls, 0, "no mod leaves the device power backend cold")
vanilla.release()
local run = T.sdk.loadMods({ "mods/power_probe" },
{ fs = T.sdk.memfs(FIXTURE) })
T.eq(#run.errors, 0,
"the sandboxed power probe loads clean (" .. tostring(run.errors[1]) .. ")")
local out = run.loader.exports.power_probe or {}
T.eq(out.state, "charging", "the public facade reports battery state")
T.eq(out.percent, 42, "the public facade reports battery percentage")
T.eq(calls, 1, "one facade read makes one platform call")
run.release()
T.love.system.getPowerInfo = nil
local unavailable = T.sdk.loadMods({ "mods/power_probe" },
{ fs = T.sdk.memfs(FIXTURE) })
out = unavailable.loader.exports.power_probe or {}
T.eq(out.state, "unknown", "a missing platform backend has a stable state")
T.eq(out.percent, nil, "a missing platform backend has no invented percentage")
unavailable.release()
T.love.system.getPowerInfo = saved
T.finish("device_power_info")
+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")
+208
View File
@@ -0,0 +1,208 @@
-- 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 and out.loveSystem:find("mod.device:powerInfo()", 1, true),
"love.system is refused and names the scoped power replacement")
T.eq(out.loveGraphics, "table", "the rest of love passes through")
T.check(out.loveAssign ~= false, "a mod cannot assign into the love facade")
-- ------- 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")
+141
View File
@@ -0,0 +1,141 @@
-- The "steps" permission gates the native step bridge (#1186): a
-- permissioned mod syncs and polls deliveries without ever seeing
-- love.system or the pending file; an unpermissioned mod gets a quiet
-- available() = false and loud, permission-naming refusals from the
-- calls that would act.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Steps = require("src.mods.Steps")
local WALKER = {
["mods/step_walker/manifest.json"] = [[{
"id": "step_walker",
"name": "Step Walker",
"version": "1.0.0",
"entry": "main.lua",
"api": 2,
"permissions": ["steps"]
}]],
["mods/step_walker/main.lua"] = [[
local mod = ...
mod.exports.available = mod.steps:available()
mod.exports.synced = mod.steps:sync()
mod.exports.poll = function() return mod.steps:poll() end
]],
}
local SECOND = {
["mods/step_second/manifest.json"] = [[{
"id": "step_second",
"name": "Step Second",
"version": "1.0.0",
"entry": "main.lua",
"api": 2,
"permissions": ["steps"]
}]],
["mods/step_second/main.lua"] = [[
local mod = ...
mod.exports.poll = function() return mod.steps:poll() end
]],
}
local UNPERMISSIONED = {
["mods/step_probe/manifest.json"] = [[{
"id": "step_probe",
"name": "Step Probe",
"version": "1.0.0",
"entry": "main.lua",
"api": 2
}]],
["mods/step_probe/main.lua"] = [[
local mod = ...
mod.exports.available = mod.steps:available()
local ok, err = pcall(function() return mod.steps:sync() end)
mod.exports.refused = not ok and tostring(err) or false
]],
}
local function merged(...)
local out = {}
for _, fixture in ipairs({ ... }) do
for path, body in pairs(fixture) do out[path] = body end
end
return out
end
local savedSync = T.love.system.syncHealthSteps
local syncCalls = 0
T.love.system.syncHealthSteps = function()
syncCalls = syncCalls + 1
return true
end
-- no mod: the bridge stays cold and a pending delivery stays on disk
T.love.filesystem.write(Steps.PENDING, '{"steps": 4312}')
local vanilla = T.sdk.loadNone({})
T.eq(syncCalls, 0, "no mod leaves the step bridge cold")
T.check(T.love.filesystem.read(Steps.PENDING) ~= nil,
"no mod leaves the pending delivery untouched")
vanilla.release()
-- a permissioned mod syncs and receives the delivery
local run = T.sdk.loadMods({ "mods/step_walker", "mods/step_second" },
{ fs = T.sdk.memfs(merged(WALKER, SECOND)) })
T.eq(#run.errors, 0,
"the permissioned mods load clean (" .. tostring(run.errors[1]) .. ")")
local walker = run.loader.exports.step_walker
T.eq(walker.available, true, "available() sees the bridge")
T.eq(walker.synced, true, "sync() reaches the bridge")
T.eq(syncCalls, 1, "one sync call makes one bridge call")
local delivery = walker.poll()
T.check(delivery and delivery.steps == 4312,
"poll() hands the mod the delivered step count")
T.check(T.love.filesystem.read(Steps.PENDING) == nil,
"the engine consumed the pending file, not the mod")
T.eq(walker.poll(), nil, "a delivery is handed out once per mod")
local second = run.loader.exports.step_second
local secondDelivery = second.poll()
T.check(secondDelivery and secondDelivery.steps == 4312,
"a second permissioned mod receives its own copy of the walk")
T.check(secondDelivery ~= delivery, "copies, not a shared table")
-- contract fields only, and a malformed delivery is dropped whole
T.love.filesystem.write(Steps.PENDING,
'{"steps": 12, "from": "a", "to": "b", "path": "/etc/passwd"}')
delivery = walker.poll()
T.eq(delivery.steps, 12, "the steps field travels")
T.eq(delivery.from, "a", "the from field travels")
T.eq(delivery.path, nil, "fields outside the contract do not travel")
T.love.filesystem.write(Steps.PENDING, "not json at all")
T.eq(walker.poll(), nil, "a malformed delivery is dropped, not raised")
T.check(T.love.filesystem.read(Steps.PENDING) == nil,
"and the bad file does not wedge the pump")
run.release()
-- without the permission: quiet probe, loud act
local probe = T.sdk.loadMods({ "mods/step_probe" },
{ fs = T.sdk.memfs(UNPERMISSIONED) })
T.eq(#probe.errors, 0,
"the unpermissioned mod loads clean (" .. tostring(probe.errors[1]) .. ")")
local out = probe.loader.exports.step_probe
T.eq(out.available, false, "available() is quietly false without the permission")
T.check(out.refused and out.refused:find('"steps" permission', 1, true),
"sync() without the permission names it")
probe.release()
-- no bridge on this build: sync reports it, nothing raises
T.love.system.syncHealthSteps = nil
local ashore = T.sdk.loadMods({ "mods/step_walker" },
{ fs = T.sdk.memfs(WALKER) })
local dry = ashore.loader.exports.step_walker
T.eq(dry.available, false, "available() is false without a native bridge")
T.eq(dry.synced, false, "sync() reports there was no bridge to ask")
ashore.release()
T.love.system.syncHealthSteps = savedSync
T.love.filesystem.remove(Steps.PENDING)
T.finish("steps_bridge")
+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",
@@ -238,9 +242,9 @@ if type(storage) == "table" then
end end
T.same(recaptured, checkpoint, T.same(recaptured, 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
@@ -267,7 +271,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
@@ -302,10 +306,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