diff --git a/CONTRIBUTING-mods.md b/CONTRIBUTING-mods.md index 86d5a57f..cd5930b8 100644 --- a/CONTRIBUTING-mods.md +++ b/CONTRIBUTING-mods.md @@ -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; `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, 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 images. -### 6. Tags +### 7. Tags Lowercase kebab strings, open vocabulary. The showcase generator lowercases and de-dupes. A recommended starting set: `beginner`, diff --git a/docs/modding.md b/docs/modding.md index 8218aba2..47b0e6ce 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -630,3 +630,41 @@ 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. + +## 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. diff --git a/docs/new-features.md b/docs/new-features.md index 87f6dc49..54d7fda8 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -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** diff --git a/docs/rfcs/0008-device-power-info.md b/docs/rfcs/0008-device-power-info.md new file mode 100644 index 00000000..cafe668f --- /dev/null +++ b/docs/rfcs/0008-device-power-info.md @@ -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. diff --git a/docs/rfcs/0009-step-bridge-permission.md b/docs/rfcs/0009-step-bridge-permission.md new file mode 100644 index 00000000..b0462c31 --- /dev/null +++ b/docs/rfcs/0009-step-bridge-permission.md @@ -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. diff --git a/mods/timekeepers_hut b/mods/timekeepers_hut new file mode 120000 index 00000000..df2790bb --- /dev/null +++ b/mods/timekeepers_hut @@ -0,0 +1 @@ +/Users/bryanbassett/Documents/development/pokemon-gen1-recomp-project/.bazinga/mods/timekeepers_hut \ No newline at end of file diff --git a/src/mods/AssetTransform.lua b/src/mods/AssetTransform.lua index f73a98c6..f6612b4e 100644 --- a/src/mods/AssetTransform.lua +++ b/src/mods/AssetTransform.lua @@ -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 diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index e36d127d..0d017cf6 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -12,12 +12,15 @@ 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") local Gen2Compat = require("src.mods.Gen2Compat") local Hooks = require("src.mods.Hooks") local Runtime = require("src.mods.Runtime") +local Steps = require("src.mods.Steps") local Loader = {} Loader.__index = Loader @@ -72,10 +75,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 +140,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 +194,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 +202,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 +252,7 @@ function Loader.new(opts) events = Events.new(), hooks = Hooks.new(), content = {}, assets = {}, exports = {}, migrations = {}, order = {}, modSave = {}, modOptions = {}, optionSchemas = {}, imageCache = {}, - modInput = {}, + modInput = {}, modEnv = {}, stepsQueues = {}, fs = (opts and opts.fs) or (love and love.filesystem), dev = dev, -- 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 -- 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 @@ -998,6 +1017,40 @@ 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, + }, + -- 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 / -- options.modOptions, the shape mods compile against is already final save = { @@ -1108,9 +1161,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, @@ -1120,9 +1175,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 @@ -1166,9 +1222,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) @@ -1200,6 +1268,7 @@ function Loader:_rollback(modId) self.optionSchemas[modId] = nil self.migrations[modId] = nil self.modSave[modId] = nil + self.stepsQueues[modId] = nil end -- 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 -- 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 diff --git a/src/mods/ManagerState.lua b/src/mods/ManagerState.lua index d1f12591..a5634834 100644 --- a/src/mods/ManagerState.lua +++ b/src/mods/ManagerState.lua @@ -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") @@ -935,13 +937,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 diff --git a/src/mods/Manifest.lua b/src/mods/Manifest.lua index 3e601c5d..95383cde 100644 --- a/src/mods/Manifest.lua +++ b/src/mods/Manifest.lua @@ -3,13 +3,15 @@ -- 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") local Manifest = {} 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 -- 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.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") @@ -272,19 +277,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), @@ -308,7 +318,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, } diff --git a/src/mods/Runtime.lua b/src/mods/Runtime.lua index 1128dedc..0b2c94a8 100644 --- a/src/mods/Runtime.lua +++ b/src/mods/Runtime.lua @@ -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 diff --git a/src/mods/SafePath.lua b/src/mods/SafePath.lua new file mode 100644 index 00000000..1272acc4 --- /dev/null +++ b/src/mods/SafePath.lua @@ -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 diff --git a/src/mods/Sandbox.lua b/src/mods/Sandbox.lua new file mode 100644 index 00000000..195ad3d2 --- /dev/null +++ b/src/mods/Sandbox.lua @@ -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 diff --git a/src/mods/Steps.lua b/src/mods/Steps.lua new file mode 100644 index 00000000..4261d89a --- /dev/null +++ b/src/mods/Steps.lua @@ -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 diff --git a/tests/integration/title_checkpoint_cold_start.lua b/tests/integration/title_checkpoint_cold_start.lua index c16347ce..b3df30e1 100644 --- a/tests/integration/title_checkpoint_cold_start.lua +++ b/tests/integration/title_checkpoint_cold_start.lua @@ -82,10 +82,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 @@ -135,11 +136,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 })) @@ -149,15 +151,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) diff --git a/tests/mod_loader_tests.lua b/tests/mod_loader_tests.lua index bdbf0784..6f0d0fae 100644 --- a/tests/mod_loader_tests.lua +++ b/tests/mod_loader_tests.lua @@ -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() diff --git a/tests/mod_manifest_tests.lua b/tests/mod_manifest_tests.lua index 9b0d9948..f7d1061d 100644 --- a/tests/mod_manifest_tests.lua +++ b/tests/mod_manifest_tests.lua @@ -359,7 +359,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"] = [[ @@ -374,7 +375,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") @@ -389,7 +390,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") @@ -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(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", @@ -409,7 +408,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 @@ -441,7 +440,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, @@ -467,7 +466,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({ diff --git a/tests/mod_render_tests.lua b/tests/mod_render_tests.lua index 871abca2..b41b24d8 100644 --- a/tests/mod_render_tests.lua +++ b/tests/mod_render_tests.lua @@ -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 diff --git a/tests/modkit/cases/checkpoints.lua b/tests/modkit/cases/checkpoints.lua index c5921158..141036b9 100644 --- a/tests/modkit/cases/checkpoints.lua +++ b/tests/modkit/cases/checkpoints.lua @@ -139,10 +139,11 @@ 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 - _G.MOD_HOOKS = mod.hooks + mod.exports.checkpoints = mod.checkpoints + mod.exports.hooks = mod.hooks end ]], } @@ -150,12 +151,12 @@ 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 +local modHooks = (loader.exports.probe or {}).hooks 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 @@ -440,7 +441,7 @@ end -- at the restored safe decision reaches its semantic auxiliary action without -- selecting a native command. 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 T.check(liveGame == battleGame, "public battle auxiliary action receives the game") 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.currentMod = nil -_G.MOD_CHECKPOINTS = nil -_G.MOD_HOOKS = nil love.math.getRandomState = oldGetRandomState love.math.setRandomState = oldSetRandomState diff --git a/tests/modkit/cases/device_power_info.lua b/tests/modkit/cases/device_power_info.lua new file mode 100644 index 00000000..f66f6af1 --- /dev/null +++ b/tests/modkit/cases/device_power_info.lua @@ -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") diff --git a/tests/modkit/cases/platform_lifecycle_hooks.lua b/tests/modkit/cases/platform_lifecycle_hooks.lua index 88d95336..6a2d70f5 100644 --- a/tests/modkit/cases/platform_lifecycle_hooks.lua +++ b/tests/modkit/cases/platform_lifecycle_hooks.lua @@ -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") diff --git a/tests/modkit/cases/sandbox.lua b/tests/modkit/cases/sandbox.lua new file mode 100644 index 00000000..d9f629ea --- /dev/null +++ b/tests/modkit/cases/sandbox.lua @@ -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") diff --git a/tests/modkit/cases/steps_bridge.lua b/tests/modkit/cases/steps_bridge.lua new file mode 100644 index 00000000..37199c64 --- /dev/null +++ b/tests/modkit/cases/steps_bridge.lua @@ -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") diff --git a/tests/modkit/cases/storage.lua b/tests/modkit/cases/storage.lua index 56564c31..a48d0131 100644 --- a/tests/modkit/cases/storage.lua +++ b/tests/modkit/cases/storage.lua @@ -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() diff --git a/tests/modkit/cases/title_playthrough_context.lua b/tests/modkit/cases/title_playthrough_context.lua index 54857a53..850a3da6 100644 --- a/tests/modkit/cases/title_playthrough_context.lua +++ b/tests/modkit/cases/title_playthrough_context.lua @@ -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", @@ -238,9 +242,9 @@ if type(storage) == "table" then end T.same(recaptured, 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 @@ -267,7 +271,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 @@ -302,10 +306,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 diff --git a/tests/modkit_tests.lua b/tests/modkit_tests.lua index 90b795f9..fc5758cc 100644 --- a/tests/modkit_tests.lua +++ b/tests/modkit_tests.lua @@ -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