mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-15 07:41:21 +02:00
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>
This commit is contained in:
@@ -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")
|
||||
Reference in New Issue
Block a user