Compare commits

...

4 Commits

Author SHA1 Message Date
bryanthaboi c0f654f8c2 Merge pull request #1195 from AverageConsumer/codex/mod-power-info
feat(mods): expose read-only device power info
2026-08-12 16:55:15 -04:00
AverageConsumer e44769a48a Expose read-only device power info to mods 2026-08-12 22:13:32 +02:00
bryanthaboi d38faab03c Delete mod-sandbox-notice.txt 2026-08-12 09:49:43 -04:00
bryanthaboi 83682f011d grandmas-kitchn 2026-08-12 09:22:30 -04:00
23 changed files with 821 additions and 144 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
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 |
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,
permissions, profile. The card is the *human-facing* one: who made this,
@@ -263,7 +304,7 @@ Two fields deserve their own note:
distributed mod never carries ROM-derived bytes, not even in its preview
images.
### 6. Tags
### 7. Tags
Lowercase kebab strings, open vocabulary. The showcase generator
lowercases and de-dupes. A recommended starting set: `beginner`,
+14
View File
@@ -514,3 +514,17 @@ local both = mod.datetime:dateTime(game, createdAt)
The live `game` supplies only the current option context. Formatting never
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.
+1
View File
@@ -19,6 +19,7 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow
* **Soft reset button combination**
* **Keyboard and controller rebinding**
* **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
* **Direct-launch options** for shortcuts, Steam entries, and handheld frontends
* **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.
+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 Runtime = require("src.mods.Runtime")
local SafePath = require("src.mods.SafePath")
local unpack = table.unpack or unpack
local loadstring = loadstring or load
@@ -31,25 +32,8 @@ AssetTransform.SOURCE_ROOT = SOURCE_ROOT
AssetTransform.DERIVED_ROOT = DERIVED_ROOT
-- ------- path sandbox
-- a relative path that cannot climb out of the root it is joined to
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
-- shared with mod:read and the manifest's own paths (src/mods/SafePath.lua)
local requireRelative = SafePath.require
-- ------- the restricted context
+68 -23
View File
@@ -12,6 +12,8 @@ local Manifest = require("src.mods.Manifest")
local Merge = require("src.mods.Merge")
local ModTargets = require("src.mods.ModTargets")
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 Semver = require("src.mods.Semver")
local Events = require("src.mods.Events")
@@ -72,10 +74,12 @@ local function orderedIds(mods, filter)
return ids
end
-- ------- dev-mode permissions tripwire
-- Attribution only: the shim delegates unconditionally and blocks nothing.
-- Installed once per process and only when the loader runs in dev mode, so a
-- player build has zero interposition.
-- ------- the require gate
-- Two jobs in one interposition. The engine_internals/network scan is
-- attribution only and stays dev-mode: it warns and delegates. The
-- 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 }
@@ -135,7 +139,8 @@ end
local function scanRequire(name)
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 function warnOnce(permission)
local key = modId .. "|" .. permission .. "|" .. name
@@ -188,6 +193,7 @@ function Loader:_installDevShim()
for id, mod in pairs(self.mods) do
devShim.permissions[id] = mod.manifest.permissionSet
end
devShim.dev = self.dev
if devShim.installed then return end
devShim.installed = true
local delegate = require
@@ -195,12 +201,22 @@ function Loader:_installDevShim()
-- only the mod's own call is the mod's doing; whatever that module
-- requires in turn is the engine wiring itself up
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.
-- Engine code keeps the real module: src/render/PaletteFX.lua:776
-- requires src.core.Game on both generations and means it.
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)
if adapter then
local key = "adapter|" .. name
@@ -235,7 +251,7 @@ function Loader.new(opts)
events = Events.new(), hooks = Hooks.new(), content = {}, assets = {},
exports = {}, migrations = {}, order = {},
modSave = {}, modOptions = {}, optionSchemas = {}, imageCache = {},
modInput = {},
modInput = {}, modEnv = {},
fs = (opts and opts.fs) or (love and love.filesystem),
dev = dev,
-- Which generation this boot is (1 or 2). Fixed at construction: the
@@ -358,11 +374,13 @@ function Loader:_writeOptionSchemas()
-- demand; using it here means older mods do not need to migrate to
-- mod.options:define just to appear in a launcher settings screen.
if schema == nil and mod.manifest.options_schema and self.fs.load then
local chunk = self.fs.load(mod.path .. "/" .. mod.manifest.options_schema)
if chunk then
local ok, rows = pcall(chunk)
if ok and type(rows) == "table" then schema = rows end
end
local ok, rows = pcall(function()
local path = SafePath.join(mod.path, mod.manifest.options_schema,
"options_schema")
local chunk = Sandbox.loadFile(self.fs, path, self:_modEnv(mod))
return chunk and chunk()
end)
if ok and type(rows) == "table" then schema = rows end
end
if schema ~= nil then
mods[id] = schema
@@ -981,6 +999,16 @@ function Loader:_api(mod)
return DateTime.dateTime(game, timestamp)
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,
},
-- namespaced per mod; M11 backs these with save.modData /
-- options.modOptions, the shape mods compile against is already final
save = {
@@ -1091,9 +1119,11 @@ function Loader:_api(mod)
-- 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
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)
local full = mod.path .. "/" .. relative
local full = SafePath.join(mod.path, relative, "mod.assets:image")
local cached = loader.imageCache[full]
if cached then return cached end
assert(love and love.graphics,
@@ -1103,9 +1133,10 @@ function Loader:_api(mod)
return image
end,
}, { __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)
local path = self.path .. "/" .. relative
return loader.fs.read(path)
return loader.fs.read(SafePath.join(self.path, relative, "mod:read"))
end
-- 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
@@ -1149,9 +1180,21 @@ function Loader:_game()
return engineRequire("src.core.Game")
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)
local path = mod.path .. "/" .. mod.manifest.entry
local chunk, err = self.fs.load(path)
local path = SafePath.join(mod.path, mod.manifest.entry, "manifest entry")
local chunk, err = Sandbox.loadFile(self.fs, path, self:_modEnv(mod))
if not chunk then error(err or ("unable to load " .. path)) end
local api = self:_api(mod)
local result = chunk(api)
@@ -1349,10 +1392,12 @@ function Loader:load(data)
-- every touch: a mod captures the facade at file scope, before Game2 has a
-- save or a world (src/mods/Gen2Compat.lua).
Gen2Compat.bind(function() return self:_game() end)
-- Dev mode wants the permissions tripwire; a Gold boot with mods on it wants
-- the Gen 1-only require report, which is the difference between "the mod
-- does nothing" and knowing why. A Gold boot with no mods pays nothing.
if self.dev or (self.generation ~= 1 and next(self.mods) ~= nil) then
-- Any boot with mods on it needs the gate, because require("io") is how a
-- mod would walk out of Sandbox.envFor. Dev mode adds the permissions
-- tripwire on top, and a Gold boot the Gen 1-only require report -- the
-- 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()
end
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 ModTargets = require("src.mods.ModTargets")
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 Semver = require("src.mods.Semver")
local Version = require("src.core.Version")
@@ -936,13 +938,18 @@ function ManagerState:schemaFor(m)
local schema = loader.optionSchemas and loader.optionSchemas[m.id]
if schema == nil and m.options_schema and m.path
and loader.fs and loader.fs.load then
local chunk = loader.fs.load(m.path .. "/" .. m.options_schema)
if chunk then
local ok, rows = pcall(chunk)
if ok and type(rows) == "table" then
schema = rows
if loader.optionSchemas then loader.optionSchemas[m.id] = schema end
end
-- mod-authored code, so it runs in the same sandbox the entry chunk does
local ok, rows = pcall(function()
local path = SafePath.join(m.path, m.options_schema, "options_schema")
local mod = loader.mods and loader.mods[m.id]
local env = mod and loader._modEnv and loader:_modEnv(mod)
or Sandbox.envFor()
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
return schema
+13 -3
View File
@@ -3,6 +3,7 @@
-- that need to stat a file, this owns shape, vocabulary and range grammar.
local Logger = require("src.core.Logger")
local ModTargets = require("src.mods.ModTargets")
local SafePath = require("src.mods.SafePath")
local Semver = require("src.mods.Semver")
local Version = require("src.core.Version")
@@ -144,6 +145,9 @@ function Manifest.validate(raw, path)
assert(type(raw.name) == "string" and raw.name ~= "", "manifest name is required")
assert(type(raw.version) == "string" and raw.version ~= "", "manifest version 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
assert(raw.api == nil or tonumber(raw.api) ~= nil, "manifest api must be a number")
@@ -229,19 +233,24 @@ function Manifest.validate(raw, path)
local affectsLink = profile ~= "content" and not language
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
assert(type(value) == "string" and value ~= "", field .. " must be a file path")
return value
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)
return {
id = raw.id,
name = raw.name,
version = raw.version,
entry = raw.entry,
entry = entry,
api = api,
priority = tonumber(raw.priority) or 0,
dependencies = array(raw.dependencies),
@@ -265,7 +274,8 @@ function Manifest.validate(raw, path)
permissionSet = permissionSet,
options_schema = optionalFile(raw.options_schema, "options_schema"),
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,
raw = raw,
}
+5
View File
@@ -28,6 +28,11 @@ Runtime.errors = nil
-- permissions tripwire knows there is nobody to attribute to
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)
Runtime.events, Runtime.hooks = events, hooks
Runtime.errors = errors
+38
View File
@@ -0,0 +1,38 @@
-- One relative-path grammar for every path a mod supplies: a mod names files
-- inside its own directory and nowhere else. love.filesystem (PhysFS) already
-- refuses "..", absolute paths and backslashes, but Loader.new takes an
-- injected fs that has no such floor, so the rule lives here and not in
-- whichever filesystem happens to be underneath.
local SafePath = {}
-- The normalized path, or nil when it could climb out of the root it is about
-- to be joined to. "." segments are dropped rather than rejected so a
-- manifest that says "./main.lua" still loads.
function SafePath.safe(rel)
if type(rel) ~= "string" or rel == "" then return nil end
if rel:sub(1, 1) == "/" then return nil end
if rel:find("\\", 1, true) then return nil end
if rel:match("^%a:") then return nil end -- windows drive-relative
local parts = {}
for segment in rel:gmatch("[^/]+") do
if segment == ".." then return nil end
if segment ~= "." then parts[#parts + 1] = segment end
end
if #parts == 0 then return nil end
return table.concat(parts, "/")
end
function SafePath.require(rel, what)
local safe = SafePath.safe(rel)
if not safe then
error(("%s must stay inside its root, got %q"):format(what, tostring(rel)), 0)
end
return safe
end
-- root .. "/" .. rel, with the traversal check in between
function SafePath.join(root, rel, what)
return root .. "/" .. SafePath.require(rel, what or "path")
end
return SafePath
+227
View File
@@ -0,0 +1,227 @@
-- The environment a mod's own code runs in. Every chunk a mod authors -- the
-- entry file, an options_schema, anything it hands to load() -- runs against
-- this table instead of _G, so the only paths it can name are the ones the
-- engine hands it (mod:read, mod.storage, mod.assets).
--
-- What this is and is not: raw io/os/ffi are the only way to name a file
-- outside the game tree at all, and they are absent here, so the reported
-- "any mod can rewrite anything in your home directory" hole closes by
-- construction. Inside the LÖVE tree this is defense in depth, not a security
-- boundary: an engine module reached through require, or ImageData:encode,
-- still writes in the save directory.
--
-- Lua 5.1/LuaJIT is the target, so setfenv is the mechanism; the 5.2+ arm
-- exists because AssetTransform's sandbox needed it and getting this wrong
-- silently hands the chunk the real globals.
local Runtime = require("src.mods.Runtime")
local SafePath = require("src.mods.SafePath")
local Sandbox = {}
-- Modules that hand a mod the disk, a raw socket or a fresh Lua state no
-- matter what this file removes from the environment. package.loaded.io is
-- the one call that would undo every other rule here.
local DENIED = {
io = "the filesystem", os = "the filesystem", debug = "the debug library",
package = "the module loader", ffi = "arbitrary C calls",
}
-- Same idea one level up: love.filesystem is reachable by name, and
-- love.thread starts a Lua state this sandbox has no say over.
local DENIED_PREFIX = { ["love"] = true, ["ffi"] = true }
-- The wire, which is what the network permission governs.
local NETWORK = { socket = true, enet = true, http = true, https = true,
ssl = true, mime = true, ltn12 = true }
local function head(name)
return (name:match("^([^%.]+)")) or name
end
-- nil when the require is allowed, else the message to fail it with.
function Sandbox.moduleDenial(name, permissionSet)
if type(name) ~= "string" then return nil end
local root = head(name)
local reason = DENIED[root]
if reason then
return ("%s is not available to mods (it grants %s); use mod.storage, "
.. "mod:read and the engine API instead"):format(name, reason)
end
if DENIED_PREFIX[root] and name ~= root then
return ("%s is not available to mods; use mod.storage, mod:read and the "
.. "engine API instead"):format(name)
end
if NETWORK[root] and not (permissionSet or {}).network then
return ("%s needs the \"network\" permission in manifest.json"):format(name)
end
return nil
end
-- ------- the love facade
-- Dropped, not narrowed: filesystem writes anywhere in the save directory
-- (including another mod's storage), thread opens a Lua state with a full
-- standard library, system.openURL launches whatever it is handed, and event
-- lets a mod quit the game out from under the player. Everything else LÖVE
-- exposes passes through, so a new module in a future LÖVE is available
-- without an edit here.
-- value is the replacement to name in the error, or true when there is none
local BLOCKED_LOVE = {
filesystem = "mod.storage and mod:read", thread = true,
system = "mod.device:powerInfo() for battery information", event = true,
}
local loveProxy
local function loveFacade()
if loveProxy or not _G.love then return loveProxy end
loveProxy = setmetatable({}, {
__index = function(_, key)
local hint = BLOCKED_LOVE[key]
if hint then
error(("love.%s is not available to mods%s"):format(key,
type(hint) == "string" and (", use " .. hint) or ""), 2)
end
return _G.love[key]
end,
__newindex = function(_, key)
error(("mods cannot assign love.%s"):format(tostring(key)), 2)
end,
})
return loveProxy
end
-- ------- the environment
-- Absent on purpose: io, package, dofile, loadfile, getfenv, setfenv, debug,
-- newproxy, module. os keeps only the clock -- getenv is how the reported
-- exploit found the user's home directory.
local SAFE_OS = { time = true, date = true, clock = true, difftime = true }
-- Per-mod copies, not the shared tables: a mod that assigns string.trim or
-- replaces table.insert changes its own view and nobody else's. The functions
-- are the same objects, so state behind them (math.randomseed's RNG) is
-- unaffected -- only the namespace is private.
local function copy(source)
if type(source) ~= "table" then return source end
local out = {}
for key, value in pairs(source) do out[key] = value end
return out
end
local function baseGlobals()
local safeOs = {}
for key in pairs(SAFE_OS) do safeOs[key] = os[key] end
return {
assert = assert, error = error, ipairs = ipairs, next = next,
pairs = pairs, pcall = pcall, xpcall = xpcall, select = select,
tonumber = tonumber, tostring = tostring, type = type, unpack = unpack,
rawequal = rawequal, rawget = rawget, rawset = rawset, rawlen = rawlen,
setmetatable = setmetatable, getmetatable = getmetatable, print = print,
collectgarbage = collectgarbage, _VERSION = _VERSION,
coroutine = copy(coroutine), math = copy(math), string = copy(string),
table = copy(table), bit = copy(bit), jit = jit, os = safeOs,
}
end
-- setfenv on 5.1/LuaJIT; on 5.2+ the env has to be handed to load itself, so
-- a caller there compiles through Sandbox.compile instead.
function Sandbox.bind(chunk, env)
if setfenv then setfenv(chunk, env) end
return chunk
end
-- Bytecode is unreviewable and, on LuaJIT, a way out of any sandbox built out
-- of environments. Mods ship source.
local function rejectBytecode(source, what)
if type(source) == "string" and source:sub(1, 1) == "\27" then
return nil, (what or "chunk") .. ": mods must ship Lua source, not bytecode"
end
return true
end
function Sandbox.compile(source, chunkname, env)
local ok, err = rejectBytecode(source, chunkname)
if not ok then return nil, err end
if setfenv then
local chunk, compileErr = loadstring(source, chunkname)
if not chunk then return nil, compileErr end
return setfenv(chunk, env)
end
return load(source, chunkname, "t", env)
end
-- The load() a mod sees. Lua 5.1 gives a loaded chunk the GLOBAL environment
-- rather than the caller's, so without this every sandboxed mod is one
-- load(mod:read(...)) away from the real _G -- which is exactly how the
-- multi-file mods in mods/ are written.
local function sandboxedLoad(env)
return function(chunk, chunkname)
if type(chunk) == "function" then
local parts = {}
while true do
local piece = chunk()
if piece == nil or piece == "" then break end
parts[#parts + 1] = piece
end
chunk = table.concat(parts)
end
if type(chunk) ~= "string" then return nil, "load expects a string or reader" end
return Sandbox.compile(chunk, chunkname or "=(load)", env)
end
end
-- The require a mod sees: the deny list lives here rather than on a stack
-- walk, because pcall(require, "io") puts a C frame where the walk would look.
-- Runtime.modRequire is how the loader's gate identifies the caller for the
-- Gen 2 facade once Runtime.currentMod has gone back to nil (a mod requiring
-- lazily from an event handler).
local function sandboxedRequire(modId, permissionSet)
return function(name, ...)
local denial = Sandbox.moduleDenial(name, permissionSet)
if denial then error(("[%s] %s"):format(modId or "mod", denial), 2) end
local previous = Runtime.modRequire
Runtime.modRequire = modId or true
local ok, result = pcall(_G.require, name, ...)
Runtime.modRequire = previous
if not ok then error(result, 0) end
return result
end
end
function Sandbox.envFor(opts)
opts = opts or {}
local env = baseGlobals()
env.love = loveFacade()
env.require = sandboxedRequire(opts.modId, opts.permissions)
local loader = sandboxedLoad(env)
env.load = loader
env.loadstring = loader
-- a mod's globals are its own: two mods no longer share a namespace, and
-- neither can reach the engine's
env._G = env
return env
end
-- fs.load keeps the real filesystem's handling of the file; the environment is
-- swapped after the fact. The 5.2+ arm has to go back to source, which is the
-- only reason fs.read is touched here.
function Sandbox.loadFile(fs, path, env)
if fs.read then
local ok, err = rejectBytecode(fs.read(path), path)
if not ok then return nil, err end
end
if setfenv then
local chunk, err = fs.load(path)
if not chunk then return nil, err end
return setfenv(chunk, env)
end
local source = fs.read and fs.read(path)
if not source then return nil, "unable to read " .. path end
return Sandbox.compile(source, "@" .. path, env)
end
Sandbox.safePath = SafePath.safe
Sandbox.requirePath = SafePath.require
return Sandbox
@@ -72,10 +72,11 @@ local function writeProbe()
fs.write("mods/cold_start_probe/manifest.json",
'{"id":"cold_start_probe","name":"cold start probe","version":"1.0.0",'
.. '"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", [[
return function(mod)
_G.COLD_STORAGE = mod.storage
_G.COLD_CHECKPOINTS = mod.checkpoints
mod.exports.storage = mod.storage
mod.exports.checkpoints = mod.checkpoints
end
]])
end
@@ -125,11 +126,12 @@ if phase == "capture" then
local game = runtime(SaveData.newGame({ version = "red" }), false)
loader.game = game
assert(loader:load({}) == true)
assert(_G.COLD_STORAGE:write(game, "history/index", { newest = "q0001" }))
local checkpoint = assert(_G.COLD_CHECKPOINTS:capture(game))
assert(_G.COLD_STORAGE:write(game, "history/q0001", checkpoint))
local probe = assert(loader.exports.cold_start_probe)
assert(probe.storage:write(game, "history/index", { newest = "q0001" }))
local checkpoint = assert(probe.checkpoints:capture(game))
assert(probe.storage:write(game, "history/q0001", checkpoint))
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"))
assert(normal.meta.playthroughId == id)
fs.write("cold-start-witness.lua", SaveSerializer.encode({ playthroughId = id }))
@@ -139,15 +141,16 @@ else
title.save.options = { volume = 7, bindings = {} }
loader.game = title
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"))))
assert(selected:context().playthroughId == witness.playthroughId)
assert(selected:read("history/index").newest == "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.options.volume == 7)
assert(SaveSerializer.encode(_G.COLD_CHECKPOINTS:capture(title))
assert(SaveSerializer.encode(probe.checkpoints:capture(title))
== SaveSerializer.encode(checkpoint))
local normal = assert(SaveData.load("red"))
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")
-- ------- discovery, dependency order, merge
-- "addon" sorts before "base" so only the dependency edge can order them
_G.MOD_TEST_ORDER = {}
-- "addon" sorts before "base" so only the dependency edge can order them.
-- 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 = {
["mods/addon/manifest.json"] = manifestJson("addon", '["base"]'),
["mods/addon/main.lua"] = [[
return function(mod)
_G.MOD_TEST_ORDER[#_G.MOD_TEST_ORDER + 1] = "addon"
mod.content.pokemon:override("MODMON", { name = "ADDONMON" })
end
]],
["mods/base/manifest.json"] = manifestJson("base"),
["mods/base/main.lua"] = [[
return function(mod)
_G.MOD_TEST_ORDER[#_G.MOD_TEST_ORDER + 1] = "base"
mod.content.pokemon:register("MODMON", { name = "BASEMON" })
mod.content.music:register("MOD_SONG", { file = "song.ogg" })
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.mods.addon ~= nil and loader.mods.base ~= nil,
"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")
check(data.pokemon.MODMON ~= nil and data.pokemon.MODMON.name == "ADDONMON",
"registered content merges into data")
@@ -411,6 +410,5 @@ local StateStack = require("src.core.StateStack")
while StateStack:top() do StateStack:pop() end
require("src.core.Music").stop()
Runtime.install(savedEvents, savedHooks)
_G.MOD_TEST_ORDER = nil
S.finish()
+6 -8
View File
@@ -341,7 +341,8 @@ check(cycleStatus.innocent.state == "loaded" and cycleData.items.FINE ~= nil,
"a mod beside the cycle loads normally")
-- ------- 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({
["mods/colorlib/manifest.json"] = manifestJson("colorlib"),
["mods/colorlib/main.lua"] = [[
@@ -356,7 +357,7 @@ end
}),
["mods/daynight/main.lua"] = [[
return function(mod)
local results = _G.MOD_FIND_RESULTS
local results = mod.exports
local color = mod.find("colorlib")
results.depVersion = color.version
results.tint = color.exports.tint("dusk")
@@ -371,7 +372,7 @@ end
["options.lua"] = "return { mods = { shelved = false } }",
}) })
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.depVersion == "1.0.0", "the handle carries the other mod's version")
check(found.optional == true, "an enabled optional dependency is findable")
@@ -380,10 +381,8 @@ check(found.disabled == nil, "find returns nil for a disabled mod")
check(found.method == true, "mod:find is tolerated alongside mod.find")
check(exportLoader.order[1] == "colorlib",
"a hard dependency executes before its dependent")
_G.MOD_FIND_RESULTS = nil
-- ------- the rest of the v2 mod object
_G.MOD_OBJECT_PROBE = {}
local objectLoader = Loader.new({ fs = memfs({
["mods/probe/manifest.json"] = manifestJson("probe", {
api = "2", description = '"probing"', priority = "3",
@@ -391,7 +390,7 @@ local objectLoader = Loader.new({ fs = memfs({
["mods/probe/data.txt"] = "hello from the mod dir",
["mods/probe/main.lua"] = [[
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.manifestApi = mod.manifest.api
mod.manifest.api = 99
@@ -423,7 +422,7 @@ end
["options.lua"] = "return { modOptions = { probe = { volume = 4 } } }",
}) })
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",
"identity fields are present")
check(probe.manifestApi == 2 and objectLoader.mods.probe.manifest.api == 2,
@@ -449,7 +448,6 @@ check(probe.onceCount == 1 and probe.stillHeard == true,
"events:once fires once and does not skip the listener behind it")
check(tostring(probe.forgery):find("may only emit", 1, true) ~= nil,
"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
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
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 = {
["mods/painter/manifest.json"] = manifest("painter", ',"priority":10'),
["mods/painter/main.lua"] = [[
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", {
label = "DIORAMA",
levels = { "OFF", "LOW", "HIGH" },
@@ -101,8 +107,6 @@ local FILES = {
priority = 20,
available = function() return T.available 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)
T.trace[#T.trace + 1] = "world:" .. tostring(ctx.tag)
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 loader = Loader.new({ fs = memfs(FILES) })
local okLoad = loader:load(data)
check(okLoad, "the pipeline mod loads clean: " .. table.concat(loader.errors, "; "))
local RT = loader.exports.painter
local trace = RT.trace
Pipelines.install(data)
check(type(data.render_pipelines) == "table",
@@ -169,24 +168,24 @@ Pipelines.setLevel("grade", 1)
eq(Pipelines.worldPipeline(), "diorama",
"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")
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")
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")
-- ------- the hardware gate
_G.__RENDER_TEST.available = false
RT.available = false
eq(Pipelines.worldPipeline(), nil,
"an unavailable pipeline never takes the world pass")
eq(Pipelines.worldPresent("world-canvas"), "world-canvas",
"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")
-- ------- the gate governs input, never the draw
@@ -282,7 +281,8 @@ local SLOPPY = {
["mods/sloppy/manifest.json"] = manifest("sloppy"),
["mods/sloppy/main.lua"] = [[
local mod = ...
local T = _G.__SLOPPY
local T = mod.exports
T.ran = 0
mod.content.render_pipelines:register("sloppy", {
label = "SLOPPY",
present = function(canvas)
@@ -302,20 +302,20 @@ local SLOPPY = {
})
]],
}
_G.__SLOPPY = { ran = 0 }
local sloppyData = {}
local sloppyLoader = Loader.new({ fs = memfs(SLOPPY) })
sloppyLoader:load(sloppyData)
local SL = sloppyLoader.exports.sloppy
Pipelines.install(sloppyData)
local composite = love.graphics.newCanvas(4, 4)
Pipelines.setLevel("sloppy", 1)
for _, bad in ipairs({ "just-a-string", true, 42 }) do
_G.__SLOPPY.ret = bad
SL.ret = bad
eq(Pipelines.present(composite), composite,
"a present returning a " .. type(bad) .. " leaves the composite untouched")
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,
"a non-canvas return does not retire the pipeline as broken")
Pipelines.setLevel("sloppy", 0)
@@ -334,11 +334,9 @@ eq(love.graphics.getCanvas(), "engine-canvas",
eq(love.graphics.getBlendMode(), "alpha",
"a present that changed blend mode cannot leak it past the fold")
Pipelines.setLevel("dirty", 0)
_G.__SLOPPY = nil
Pipelines.reset()
Pipelines.install(nil)
_G.__RENDER_TEST = nil
-- ------- and with no mods at all, the whole subsystem is inert
+3 -4
View File
@@ -139,20 +139,20 @@ local files = {
["mods/probe/manifest.json"] =
'{"id":"probe","name":"probe","version":"1.0.0",'
.. '"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"] = [[
return function(mod) _G.MOD_CHECKPOINTS = mod.checkpoints end
return function(mod) mod.exports.checkpoints = mod.checkpoints end
]],
}
local game, ow = makeGame()
local loader = Loader.new({ fs = memfs(files) })
loader.game = game
T.check(loader:load({}) == true, "checkpoint fixture mod loads")
local checkpoints = _G.MOD_CHECKPOINTS
local checkpoints = (loader.exports.probe or {}).checkpoints
T.check(type(checkpoints) == "table",
"Loader exposes mod.checkpoints through the public mod object")
if type(checkpoints) ~= "table" then
Runtime.events, Runtime.hooks = savedEvents, savedHooks
_G.MOD_CHECKPOINTS = nil
T.finish()
end
@@ -435,7 +435,6 @@ end
Runtime.events, Runtime.hooks = savedEvents, savedHooks
Runtime.currentMod = nil
_G.MOD_CHECKPOINTS = nil
love.math.getRandomState = oldGetRandomState
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
if not paused then nextFn(game, dt) end
end)
local vetoQuit = false
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()
end)
-- test-only knobs, read back through mod.storage-free globals since
-- this fixture never leaves the process
_G.__fixturePlatformBridge = {
setPaused = function(v) paused = v end,
extraPolls = function() return extraPolls end,
}
-- test-only knobs on mod.exports: a sandboxed mod has no shared _G to
-- smuggle them through (src/mods/Sandbox.lua)
mod.exports.setPaused = function(v) paused = v end
mod.exports.extraPolls = function() return extraPolls end
mod.exports.setVetoQuit = function(v) vetoQuit = v end
]],
}
@@ -43,23 +43,23 @@ do
T.eq(#run.errors, 0,
"the fixture mod loads clean (" .. tostring(run.errors[1]) .. ")")
local bridge = run.loader.exports.fix_platform_bridge
local calls = 0
local fakeGame = { update = function(self, dt) calls = calls + 1 end }
_G.__fixturePlatformBridge.setPaused(false)
bridge.setPaused(false)
PlatformHooks.update(fakeGame, 1 / 60)
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")
_G.__fixturePlatformBridge.setPaused(true)
bridge.setPaused(true)
PlatformHooks.update(fakeGame, 1 / 60)
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")
run.release()
_G.__fixturePlatformBridge = nil
end
-- core.quit_to_launcher: a subscriber can veto without the vanilla
@@ -70,11 +70,8 @@ do
T.eq(#run.errors, 0,
"the fixture mod loads clean (" .. tostring(run.errors[1]) .. ")")
local realGetenv = os.getenv
os.getenv = function(name)
if name == "FIXTURE_VETO_QUIT" then return "1" end
return realGetenv(name)
end
local bridge = run.loader.exports.fix_platform_bridge
bridge.setVetoQuit(true)
local vanillaCalls = 0
local vetoed = PlatformHooks.quitToLauncher(function()
vanillaCalls = vanillaCalls + 1
@@ -82,7 +79,7 @@ do
end)
T.eq(vetoed, false, "a subscriber can veto the return-to-launcher decision")
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)
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")
+5 -5
View File
@@ -66,12 +66,13 @@ end
local files = {
["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"] = [[
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/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)
@@ -80,12 +81,12 @@ local current = game("red", "play-a")
loader.game = current
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",
"Loader exposes mod.storage through the public mod object")
if type(alpha) ~= "table" or type(beta) ~= "table" then
Runtime.events, Runtime.hooks = savedEvents, savedHooks
_G.MOD_STORAGE_ALPHA, _G.MOD_STORAGE_BETA = nil, nil
T.finish()
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.currentMod = nil
_G.MOD_STORAGE_ALPHA, _G.MOD_STORAGE_BETA = nil, nil
T.finish()
@@ -57,13 +57,16 @@ local files = {
["mods/probe/manifest.json"] =
'{"id":"probe","name":"probe","version":"1.0.0",'
.. '"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"] = [[
return function(mod)
_G.MOD_TITLE_STORAGE = mod.storage
_G.MOD_TITLE_CHECKPOINTS = mod.checkpoints
local out = mod.exports
out.storage = mod.storage
out.checkpoints = mod.checkpoints
out.restoreCount = 0
mod.events:on("checkpoint.restored", function(ev)
_G.MOD_TITLE_RESTORE_COUNT = (_G.MOD_TITLE_RESTORE_COUNT or 0) + 1
_G.MOD_TITLE_RESTORE_KIND = ev.kind
out.restoreCount = out.restoreCount + 1
out.restoreKind = ev.kind
end)
end
]],
@@ -82,7 +85,8 @@ local loader = Loader.new({ fs = fs })
loader.game = active
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")
if type(storage) == "table" then
local written, writeCode, writeMessage = storage:write(active, "history/index", {
@@ -178,7 +182,7 @@ if type(storage) == "table" then
end
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")
local checkpoint = checkpoints and checkpoints:capture(runtime)
T.check(type(checkpoint) == "table",
@@ -226,9 +230,9 @@ if type(storage) == "table" then
"title bootstrap never rewrites the first normal save")
T.same(checkpoints:capture(titleRuntime), 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")
T.eq(_G.MOD_TITLE_RESTORE_KIND, "overworld",
T.eq(probe.restoreKind, "overworld",
"title resume lifecycle reports the reconstructed checkpoint kind")
-- Force a failure after restoreCheckpointSave has already installed the
@@ -255,7 +259,7 @@ if type(storage) == "table" then
version = "red", meta = { playthroughId = originalId },
}, fs).savedAt, anchoredAt,
"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")
end
@@ -290,10 +294,6 @@ end
Runtime.events, Runtime.hooks = savedEvents, savedHooks
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.loadOptions = originalLoadOptions
love.filesystem = realFs
+6 -4
View File
@@ -125,8 +125,11 @@ local hotFiles = {
["mods/hot_mod/main.lua"] = [[
return function(mod)
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()
_G.MODKIT_TEST_READY = (_G.MODKIT_TEST_READY or 0) + 1
out.ready = (out.ready or 0) + 1
end)
end
]],
@@ -148,7 +151,6 @@ local function freshHotData()
return d
end
_G.MODKIT_TEST_READY = 0
local hotData = freshHotData()
local game = { data = hotData, save = { modData = {} } }
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")
check(flushed >= 1, "reload flushed the registered caches")
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
check(musicStops >= 1, "reload stops chip music through the cache bus")
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(hotData.pokemon.FIXMON_A.baseStats.speed == 45,
"broken mod rolls back to pristine base")
_G.MODKIT_TEST_READY = nil
-- ------- dev console: repl, verbs, tracer, input isolation