Files
gen1recomp/tests/modkit/cases/steps_bridge.lua
T
Myles Resnick bde606f966 Permission-gated step bridge for sandboxed mods
The sandbox blocks love.system and love.filesystem, which orphans the
native step bridge (#452, #489): its one consumer can no longer call
syncHealthSteps or read steps_pending.json (#1186).

Adds a "steps" manifest permission (shown to the player like the
others) gating a mod.steps facade: available() probes the bridge
quietly, sync() forwards the async refresh, poll() hands the mod its
copy of a delivery. The engine owns the pending file -- mods never name
a path and receive only { steps, from, to }. Without the permission the
acting calls name it, following the network gate. No new events, hooks
or registries; nothing removed.

RFC 0009. Tests: tests/modkit/cases/steps_bridge.lua (no-mod cold
bridge, permissioned sync/poll, per-mod copies, contract-field
filtering, malformed-delivery drop, unpermissioned refusal, bridgeless
build).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 11:19:06 -04:00

142 lines
4.9 KiB
Lua

-- 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")